diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5a1274 --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# KnoTrack environment configuration. +# Copy to .env for local development. See docs/TRD.md §7 for full details +# on every variable below. + +# --- Required --- + +# Postgres connection string. postgres://user:pass@host:port/db +DATABASE_URL=postgres://knotrack_app:knotrack_dev_pw@127.0.0.1:5432/knotrack_scratch + +# Comma-separated list of accepted bearer tokens (TRD §4). The server +# refuses to boot if this is unset or empty. Generate one with: +# npm run generate-token +KNOTRACK_API_TOKENS=kt_dev_local_token_change_me + +# Base64-encoded 32-byte key for AES-256-GCM adapter-credential +# encryption (TRD §5). Generate with: openssl rand -base64 32 +KNOTRACK_ENCRYPTION_KEY= + +# --- Optional (defaults shown) --- + +NODE_ENV=development +PORT=8080 +HOST=0.0.0.0 + +# "require" in production, "disable" otherwise. +DATABASE_SSL_MODE=disable + +# Verify the Postgres server's TLS certificate when DATABASE_SSL_MODE=require +# (default: true). Only set to false for a broken/self-signed local dev +# certificate — never in production, or the TLS channel is encrypted but +# unauthenticated (vulnerable to MITM). +KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED=true + +# Postgres statement_timeout in ms — bounds how long any single query may +# run before Postgres cancels it, so a slow/deadlocked query can't hang a +# request indefinitely. +KNOTRACK_DB_STATEMENT_TIMEOUT_MS=30000 + +KNOTRACK_DB_POOL_MAX=10 + +KNOTRACK_DRIFT_SCAN_TRACK_CAP=500 +KNOTRACK_DRIFT_SCAN_ITEM_CAP=5000 +KNOTRACK_DRIFT_SCAN_TIMEOUT_MS=5000 + +KNOTRACK_ROADMAP_TRACK_CAP=200 +KNOTRACK_ROADMAP_ITEM_PER_TRACK_CAP=100 + +KNOTRACK_STALE_TRACK_DAYS=14 +KNOTRACK_NEXT_STEPS_LIMIT=5 + +KNOTRACK_GITHUB_SYNC_TIMEOUT_MS=8000 +KNOTRACK_LINEAR_SYNC_TIMEOUT_MS=8000 + +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore index 872d5f6..41db8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,143 +1,8 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files +dist/ +coverage/ .env -.env.* -!.env.example - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist -.output - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp directory -.temp - -# Sveltekit cache directory -.svelte-kit/ - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# Firebase cache directory -.firebase/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# pnpm -.pnpm-store - -# yarn v3 -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Vite files -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -.vite/ +*.log +.adversarial-review/ +.stryker-tmp/ +stryker-report.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..11bbdac --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +coverage +docs diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1d9d0f6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c98ffff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# KnoTrack — self-hosted MCP server +# Multi-stage build: compile TypeScript, then run the plain JS output on a +# slim Node 20 base with only production dependencies installed. + +FROM node:20.20-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +COPY scripts ./scripts +RUN npm run build + +FROM node:20.20-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +# scripts/migrate.ts resolves MIGRATIONS_DIR relative to its own compiled +# location (dist/scripts/migrate.js -> dist/migrations), so the SQL files +# must land there, not at /app/migrations. +COPY migrations ./dist/migrations + +# Run as the non-root user node:20.20-slim already ships (uid 1000), not root. +USER node + +# Migrations are run as a separate deploy-time step (docs/TRD.md §7/§8), +# never automatically on container start, e.g.: +# docker run --rm --env-file .env node dist/scripts/migrate.js +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:8080/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" +CMD ["node", "dist/src/index.js"] diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..8decc02 --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +KnoTrack +Copyright 2026 Paul Poulose + +This product includes software developed as part of the KnoTrack project +(https://github.com/SathiaAI/KnoTrack). + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy +of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +If you redistribute KnoTrack or a derivative work, in source or binary +form, §4(d) of the License requires that the attribution notices below +remain available to recipients — in a NOTICE file, in source-file headers, +in accompanying documentation, or wherever a display of third-party +notices would normally appear. You may add your own attribution notices +alongside these; NOTICE-file content does not modify the License's terms. +Retaining these notices (in whatever form) is how downstream users find +their way back to the original project and its license terms — please +don't strip them, even in a fork. diff --git a/README.md b/README.md index 3c9887f..d53f945 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,138 @@ -# knotrack -KnoTrack: a project manager for your projects, not an orchestrator. Steps into a project, assesses status against source-of-truth docs, sequences work, and catches drift — usable from Claude, Windsurf, Codex CLI, LM Studio, Goose, Hermes, or any MCP-speaking harness. +# KnoTrack + +A project manager for your projects — not an orchestrator. + +KnoTrack steps into an existing software project, reads its source-of-truth +documents, reports current status, helps sequence upcoming work against +declared dependencies, and detects drift (work happening that isn't +reflected in the plan, or happening out of sequence). It never assigns or +dispatches work — it tracks, sequences, and reports. If your project +already has an orchestrator, KnoTrack supports it rather than replacing it. + +It speaks [MCP](https://modelcontextprotocol.io) (the 2026-07-28, stateless +revision of the spec), so it works the same way from Claude Code/Cowork, +Windsurf, Codex CLI, LM Studio, Goose, Hermes, or any other MCP-speaking +harness — not just one vendor's tool. + +## Status + +Pre-release, v0.1.0. 5 of the 14 planned tools are implemented and +dogfooded (KnoTrack tracks its own build using itself — see +[`scripts/seed-self.ts`](scripts/seed-self.ts) and `docs/ROADMAP.md`'s T1); +the remaining 9 are registered with their real, TRD-accurate input schemas +so `tools/list` already reflects the full surface, but each currently +returns a clear "not yet implemented" error rather than doing partial work. + +Every change lands through a mandatory adversarial-review gate before it's +considered reviewed: deterministic checks (build, lint, typecheck, unit, +integration, secrets scan, dependency audit, SAST, IaC scan, migration +lint, mutation testing) plus an independent panel of reviewer models from +providers uninvolved in writing the code, with a verdict computed by +script — never self-assessed. The latest full run's verdict is **PASS** +(13/13 gates, 5 independent reviewers, 3 high/critical findings all +confirmed and fixed with regression tests, 11 lower-severity findings +triaged and tracked as backlog). Review run artifacts are kept locally +(`.adversarial-review/`, gitignored) rather than committed, since they can +include full diffs and raw model output. + +## Tools + +| Tool | Status | Purpose | +| --- | --- | --- | +| `kt_register_project` | implemented | Register (or upsert) a project by its source (`github`, `local`, etc.) | +| `kt_get_project_status` | implemented | Current status summary: tracks, items, drift flags | +| `kt_create_track` | implemented | Create a track (a sequenced line of work) under a project | +| `kt_create_item` | implemented | Create an item within a track, auto- or explicitly-sequenced | +| `kt_record_session_summary` | implemented | Record a session's summary and re-check for drift | +| `kt_list_tracks` | planned | List a project's tracks, optionally filtered by status | +| `kt_get_track` | planned | Track detail: items plus dependency graph | +| `kt_get_next_steps` | planned | Suggested next items given current status and dependencies | +| `kt_record_decision` | planned | Record a decision and the context behind it | +| `kt_update_item_status` | planned | Move an item's status forward (or flag it blocked) | +| `kt_check_drift` | planned | On-demand drift scan across a project | +| `kt_render_roadmap` | planned | Render a roadmap view from tracked items | +| `kt_sync_to_github` | planned | One-way sync of tracked items to GitHub Issues | +| `kt_sync_to_linear` | planned | One-way sync of tracked items to Linear | + +Full request/response contracts for every tool, implemented or planned, +are in [`docs/TRD.md`](docs/TRD.md). + +## Quick start + +Requirements: Node.js 20.12+, a Postgres database. + +```bash +git clone https://github.com/SathiaAI/KnoTrack.git +cd KnoTrack +npm install + +cp .env.example .env +# edit .env: set DATABASE_URL, then generate the other two required values +npm run generate-token # -> KNOTRACK_API_TOKENS +openssl rand -base64 32 # -> KNOTRACK_ENCRYPTION_KEY + +npm run migrate +npm run dev # starts the MCP server (stateless HTTP, see docs/TRD.md §3) +``` + +Point any MCP-speaking client at `http://localhost:8080/mcp` with the +bearer token you generated. `GET /health` is intentionally unauthenticated +and checks DB connectivity from its own isolated connection pool, so it +stays truthful even when the main pool is under load. + +To see KnoTrack track its own build (the "dogfood" step referenced in +`docs/ROADMAP.md`'s T1), run `npm run seed-self` after migrating. + +## Configuration + +Every environment variable, required and optional, is documented inline in +[`.env.example`](.env.example) and in full in +[`docs/TRD.md`](docs/TRD.md) §7 — connection pooling, statement timeouts, +drift-scan caps, roadmap caps, and the TLS/encryption settings. + +## Development + +```bash +npm test # unit + integration (vitest) +npm run test:mutation # mutation testing (stryker) — see stryker.conf.json +npm run lint # eslint +npm run typecheck # tsc --noEmit +npm run format # prettier --write +``` + +Integration tests need a reachable Postgres matching `DATABASE_URL`; +`tests/integration/helpers.ts` truncates between tests rather than +recreating the schema. + +## Documentation + +- [`docs/PRD.md`](docs/PRD.md) — product requirements +- [`docs/TRD.md`](docs/TRD.md) — technical requirements, full tool contracts +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — solution design, diagrams +- [`docs/DATABASE_SCHEMA.md`](docs/DATABASE_SCHEMA.md) — schema + ERD +- [`docs/TEST_CASES.md`](docs/TEST_CASES.md) — positive/negative test matrix +- [`docs/ROADMAP.md`](docs/ROADMAP.md) — phased build plan + +## Self-hosting + +KnoTrack is self-hosted — you run your own instance against your own +database; there is no central KnoTrack service. A [`Dockerfile`](Dockerfile) +is included for containerized deployment; wire it to whatever Postgres and +scheduler your infrastructure already uses (Render+Supabase, Railway, +Fly.io, or your own hosts all work — KnoTrack itself has no +infrastructure-specific dependencies beyond Postgres and a Node runtime). + +## License and attribution + +Apache License 2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE). + +This is genuinely open source: you can fork it, modify it, and run it +commercially. What the license asks in return (Apache-2.0 §4(d)) is that +the attribution notices in `NOTICE` remain available to anyone you +redistribute to — that's what keeps credit attached to the project as it +spreads. You're free to extend `NOTICE` with your own notices; it doesn't +have to stay byte-for-byte unmodified, and adding to it doesn't change the +license terms. If you build something publicly on top of KnoTrack, a +visible mention ("built on KnoTrack") is appreciated but not legally +required beyond keeping those notices available; please don't strip +attribution and present it as an unrelated original work. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e0dd53d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,592 @@ +# KnoTrack — Solution Design (ARCHITECTURE.md) + +KnoTrack is a **self-hosted, single-tenant MCP server** for project-management +workflow (status, sequencing, drift detection) that many different AI agent +harnesses talk to over the Model Context Protocol. There is no central +multi-tenant service: every installer runs their own instance, pointed at +their own Postgres database, holding their own adapter credentials. + +KnoTrack targets the **stateless 2026-07-28 MCP spec**: the server keeps no +server-side session memory between calls. Every tool call is a fully +self-contained request carrying explicit IDs (`project_id`, `track_id`, +`item_id`, …). This is a load-bearing constraint on the whole design — see +[§6](#6-why-this-cannot-become-an-orchestrator-by-accident) for why it also +keeps KnoTrack from ever becoming a work-dispatching orchestrator. + +**Stack:** Node.js 20 + TypeScript, `@modelcontextprotocol/sdk`, Fastify, +Postgres via `pg` + `node-pg-migrate`. + +**Deploy targets (identical architecture on all three):** Render + Supabase, +Railway + Postgres, Fly.io + Postgres. Same Docker image, same Node process, +same schema/migrations — the only thing that differs is *where* compute and +Postgres are provisioned. + +--- + +## 1. System Context + +```mermaid +flowchart LR + subgraph Harnesses["AI Agent Harnesses (MCP Clients)"] + CC["Claude Code / Cowork"] + WS["Windsurf"] + CX["Codex CLI"] + LM["LM Studio"] + GO["Goose"] + HE["Hermes"] + end + + KT["KnoTrack MCP Server
Node.js 20 + TypeScript
Fastify + @modelcontextprotocol/sdk
(one instance per installer)"] + PG[("Postgres
Project / Track / Item / Event / Decision")] + GH["GitHub API"] + LN["Linear API"] + + CC -->|"MCP, stateless 2026-07-28
bearer token per request"| KT + WS -->|MCP| KT + CX -->|MCP| KT + LM -->|MCP| KT + GO -->|MCP| KT + HE -->|MCP| KT + + KT -->|"SQL via pg, tx-scoped"| PG + KT -->|"kt_sync_to_github
(encrypted PAT)"| GH + KT -->|"kt_sync_to_linear
(encrypted API key)"| LN +``` + +Key properties visible at this level: + +- **Fan-in, not fan-out**: six different harness ecosystems converge on one + MCP surface; KnoTrack does not know or care which harness is calling, only + which bearer token it presented. +- **Single source of truth**: Postgres is the only durable state. GitHub and + Linear are *targets* KnoTrack pushes summaries to, never sources it reads + authority from. +- **No inter-installer traffic**: nothing here talks to any other KnoTrack + instance. Each box in "Postgres" is one installer's private database. + +--- + +## 2. Component Diagram (server internals) + +```mermaid +flowchart TB + subgraph Proc["KnoTrack MCP Server — single Node.js process"] + TR["Transport & Tool Router
(@modelcontextprotocol/sdk transport,
stdio / streamable HTTP)"] + AUTH["Auth Middleware
(per-request bearer token validation,
no session state)"] + + subgraph Domain["Domain / Service Layer (one service per entity)"] + PSVC["Project Service"] + TSVC["Track Service"] + ISVC["Item Service"] + ESVC["Event Service"] + DSVC["Decision Service"] + end + + DRIFT["Drift Detection Engine
(structural check over Event log
+ Track/Item dependency graph)"] + RENDER["Doc Renderer
(kt_render_roadmap —
pure function over current DB rows)"] + + subgraph Adapters["Adapter Clients"] + GHA["GitHub Adapter Client"] + LNA["Linear Adapter Client"] + end + + DAL["Data Access Layer
(pg pool, node-pg-migrate,
transaction helpers)"] + end + + PG[("Postgres")] + GH["GitHub API"] + LN["Linear API"] + + TR --> AUTH + AUTH --> PSVC + AUTH --> TSVC + AUTH --> ISVC + AUTH --> ESVC + AUTH --> DSVC + AUTH --> RENDER + AUTH --> GHA + AUTH --> LNA + + ESVC --> DRIFT + DRIFT --> TSVC + DRIFT --> ISVC + DRIFT --> ESVC + + PSVC --> DAL + TSVC --> DAL + ISVC --> DAL + ESVC --> DAL + DSVC --> DAL + DRIFT --> DAL + RENDER --> DAL + + DAL --> PG + GHA --> GH + LNA --> LN +``` + +Notes on the module boundaries: + +- **Transport & Tool Router** owns MCP protocol framing and dispatches each + of the 14 tools to exactly one service method. It holds no business logic. +- **Auth Middleware** sits between the router and every service — it is not + optional per-tool, it is structurally in the path for all 14 tools. +- **Domain/Service Layer** is one service per entity (Project, Track, Item, + Event, Decision) so a schema or rule change to one entity never leaks into + another's code path. +- **Drift Detection Engine** is a standalone module, not a method hanging off + Event Service, because it is invoked from two call sites: inline from + `kt_record_session_summary` and standalone from `kt_check_drift`. It reads + the dependency graph and event history; it never mutates Track/Item rows, + only writes drift findings tied to the triggering event. +- **Doc Renderer** depends only on the Data Access Layer, never on the + service layer's in-memory objects, so `kt_render_roadmap` is guaranteed to + reflect committed DB state and nothing else — see §3b. +- **Adapter Clients** are the only modules with outbound internet calls + besides Postgres. They are invoked directly by the router (via auth), not + by the domain services, so a GitHub/Linear outage cannot block a write to + Postgres. + +--- + +## 3. Sequence Diagrams + +### 3a. `kt_record_session_summary` end-to-end, including inline drift check + +```mermaid +sequenceDiagram + participant Client as MCP Client (harness) + participant Router as Tool Router + participant Auth as Auth Middleware + participant ESvc as Event Service + participant DB as Postgres + participant Drift as Drift Detection Engine + participant TSvc as Track/Item Service + + Client->>Router: call_tool kt_record_session_summary(project_id, track_id, items_touched, files_touched, summary_text, bearer_token) + Router->>Auth: validate(bearer_token) + Auth->>DB: SELECT client_tokens WHERE token_hash = ? + DB-->>Auth: active token row + scopes + Auth-->>Router: authorized(client_id) + + Router->>ESvc: recordSessionSummary(payload) + ESvc->>DB: BEGIN (SERIALIZABLE) + ESvc->>DB: SELECT track FOR UPDATE (row lock on track_id) + ESvc->>DB: INSERT INTO events (...) RETURNING id + ESvc->>TSvc: getDependencyGraph(track_id) + TSvc->>DB: SELECT tracks/items + depends_on edges + DB-->>TSvc: graph rows + TSvc-->>ESvc: dependency graph + + ESvc->>Drift: checkDrift(new_event, graph) + Drift->>DB: SELECT prior events for track_id / items_touched + DB-->>Drift: event history + Drift-->>ESvc: DriftReport{out_of_order[], orphans[], has_drift} + + ESvc->>DB: INSERT INTO drift_findings (event_id, findings) [only if has_drift] + ESvc->>DB: COMMIT + DB-->>ESvc: ok + + ESvc-->>Router: {event_id, drift_report} + Router-->>Client: tool_result {event_id, drift: {...}} +``` + +The row lock (`SELECT ... FOR UPDATE`) is acquired **before** the drift +computation reads item/event state, so drift always evaluates against a +consistent, non-racing snapshot — detailed in §7. + +### 3b. `kt_render_roadmap` generating a document from current DB state + +```mermaid +sequenceDiagram + participant Client as MCP Client + participant Router as Tool Router + participant Auth as Auth Middleware + participant Render as Doc Renderer + participant DB as Postgres + + Client->>Router: call_tool kt_render_roadmap(project_id, bearer_token) + Router->>Auth: validate(bearer_token) + Auth-->>Router: authorized + + Router->>Render: render(project_id) + Render->>DB: SELECT project + Render->>DB: SELECT tracks WHERE project_id (+ depends_on edges) + Render->>DB: SELECT items WHERE track_id IN (...) ORDER BY sequence_position + Render->>DB: SELECT latest decision per track + Render->>DB: SELECT latest event / drift flag per track + DB-->>Render: rows: tracks, items, decisions, drift flags + Render->>Render: assemble markdown (pure transform, no writes) + Render-->>Router: {roadmap_markdown, generated_at, source_snapshot_hash} + Router-->>Client: tool_result {roadmap_markdown} + + Note over Render,DB: No table stores the rendered document.
Every call recomputes from current rows —
the roadmap is never hand-edited because
there is nowhere to hand-edit it into. +``` + +### 3c. Bearer-token auth check on a rejected (401) request + +```mermaid +sequenceDiagram + participant Client as MCP Client + participant Router as Tool Router + participant Auth as Auth Middleware + participant DB as Postgres + + Client->>Router: call_tool kt_get_project_status(project_id, bearer_token=invalid) + Router->>Auth: validate(bearer_token) + Auth->>Auth: hash(bearer_token) + Auth->>DB: SELECT * FROM client_tokens WHERE token_hash = ? + DB-->>Auth: no matching row (or revoked / expired) + Auth-->>Router: Unauthorized + Router-->>Client: HTTP 401 / JSON-RPC error
{code: -32001, message: "invalid or expired bearer token"} + + Note over Router,DB: Rejected before any service or drift code runs.
No session, no partial state, nothing to roll back —
statelessness makes auth failure a pure no-op. +``` + +--- + +## 4. Deployment Architecture + +All three targets run the **same Docker image** and the **same Node.js +process** (one Fastify HTTP listener speaking MCP + a health endpoint). Only +the placement of compute and Postgres changes. + +### 4a. Render + Supabase + +```mermaid +flowchart TB + subgraph RenderCloud["Render"] + WEB["Render Web Service
Docker image: knotrack-server
Node.js 20 process"] + ENV["Render Environment Group
bearer tokens, adapter secrets
(encrypted)"] + end + subgraph SupabaseCloud["Supabase"] + SPG[("Supabase Postgres
managed, same schema/migrations")] + end + + Client["MCP Client Harness"] -->|HTTPS / MCP| WEB + WEB -->|"Postgres wire protocol, SSL,
pg connection pool"| SPG + ENV -.->|injected at boot| WEB + WEB -->|HTTPS| GH["GitHub API"] + WEB -->|HTTPS| LN["Linear API"] +``` + +### 4b. Railway + Postgres + +```mermaid +flowchart TB + subgraph RailwayProj["Railway Project"] + SVC["Railway Service
Docker image: knotrack-server
Node.js 20 process"] + RPG[("Railway Postgres Plugin
managed Postgres instance")] + VARS["Railway Environment Variables
secrets, adapter creds
(encrypted)"] + end + + Client["MCP Client Harness"] -->|HTTPS / MCP| SVC + SVC -->|"Private network,
Postgres wire protocol"| RPG + VARS -.->|injected at boot| SVC + SVC -->|HTTPS| GH["GitHub API"] + SVC -->|HTTPS| LN["Linear API"] +``` + +### 4c. Fly.io + Postgres + +```mermaid +flowchart TB + subgraph FlyApp["Fly.io App"] + MACHINE["Fly Machine(s)
Docker image: knotrack-server
Node.js 20 process, 1+ regions"] + SECRETS["Fly Secrets
fly secrets set —
adapter creds, bearer tokens"] + end + subgraph FlyPG["Fly Postgres App (separate Fly app)"] + FPG[("Fly Postgres cluster
managed via flyctl postgres")] + end + + Client["MCP Client Harness"] -->|HTTPS / MCP| MACHINE + MACHINE -->|"Postgres wire protocol over
Fly private network (6PN)"| FPG + SECRETS -.->|injected at boot| MACHINE + MACHINE -->|HTTPS| GH["GitHub API"] + MACHINE -->|HTTPS| LN["Linear API"] +``` + +**Why identical is achievable:** the process reads its Postgres connection +string and adapter secrets from environment variables only; it makes no +assumption about the platform it runs on (no Render-specific or Fly-specific +SDK calls, no reliance on a platform's native cron/queue). `node-pg-migrate` +runs the same migration set against whichever Postgres the connection string +points at. Swapping deploy target is a matter of re-pointing `DATABASE_URL` +and re-running migrations, not a code change. + +--- + +## 5. Drift Detection Algorithm + +Drift detection is **structural and server-computed** — it never asks an LLM +whether something looks wrong; it walks the declared dependency graph +against the append-only Event log. It runs from two call sites +(`kt_record_session_summary` inline, and `kt_check_drift` standalone) against +the same function. + +```text +# Inputs: +# track_id — the track this check is scoped to +# new_event — Event{ id, track_id, items_touched: [item_id], +# files_touched: [path], created_at } +# (null for a standalone kt_check_drift call — +# in that case the check runs over the whole +# committed event log instead of one new event) +# graph — declared structure, loaded fresh from Postgres: +# tracks: Track{ id, depends_on: [track_id] } +# items: Item{ id, track_id, sequence_position, +# depends_on: [item_id] } +# event_log — all committed Events for track_id, ordered by +# created_at (append-only, never mutated) + +function checkDrift(track_id, new_event, graph, event_log) -> DriftReport: + + findings = { out_of_order: [], orphans: [], has_drift: false } + + # ---- Precompute completion state from the Event log ---- + # An item is "done" once some Event recorded a status_transition to + # 'done' for it. This is derived, not stored redundantly. + completed_items = set() + for ev in event_log: + if ev.status_transition == 'done' and ev.item_id is not None: + completed_items.add(ev.item_id) + + events_to_check = [new_event] if new_event is not None else event_log + + for ev in events_to_check: + + # ---- Check (a): item touched out of declared dependency order ---- + for item_id in ev.items_touched: + item = graph.items.get(item_id) + if item is None: + continue # unknown item_id — surfaces via orphan check below + + # (a-i) explicit item-level dependency edges + for dep_id in item.depends_on: + if dep_id not in completed_items: + findings.out_of_order.append({ + event_id: ev.id, + item_id: item_id, + violated_dependency: dep_id, + reason: "item touched before its declared " + "dependency was marked done" + }) + + # (a-ii) track-level dependency edges (this item's track + # depends on another track that isn't fully done yet) + owning_track = graph.tracks.get(item.track_id) + for dep_track_id in owning_track.depends_on: + if not allItemsDone(dep_track_id, graph, completed_items): + findings.out_of_order.append({ + event_id: ev.id, + item_id: item_id, + violated_dependency_track: dep_track_id, + reason: "item touched while an upstream track " + "dependency is still incomplete" + }) + + # (a-iii) sequence_position ordering within the same track: + # touching item N while an earlier-sequenced, still-open + # item M < N exists in the same track is drift even absent + # an explicit depends_on edge, because sequence_position IS + # the declared order. + earlier_open = [ + other for other in graph.items.values() + if other.track_id == item.track_id + and other.sequence_position < item.sequence_position + and other.id != item_id + and other.id not in completed_items + ] + if earlier_open: + findings.out_of_order.append({ + event_id: ev.id, + item_id: item_id, + skipped_items: [o.id for o in earlier_open], + reason: "touched an item ahead of earlier, still-open " + "items in this track's sequence_position order" + }) + + # ---- Check (b): orphan work — files touched with no matching Item ---- + # A file is "matched" if it appears in files_touched on the SAME + # event alongside an items_touched entry for an item declared in + # this track. If a file is touched in a track's session but no + # item_touched in that same event maps to any Item in the track + # at all, it's orphan work — implicit scope with no declared unit. + if len(ev.items_touched) == 0 and len(ev.files_touched) > 0: + for file_path in ev.files_touched: + findings.orphans.append({ + event_id: ev.id, + file_path: file_path, + reason: "files touched in this track's session with " + "no corresponding Item recorded on the event" + }) + else: + declared_ids = set(ev.items_touched) & set(graph.items.keys()) + if len(declared_ids) == 0 and len(ev.files_touched) > 0: + for file_path in ev.files_touched: + findings.orphans.append({ + event_id: ev.id, + file_path: file_path, + reason: "items_touched referenced no Item that " + "exists in this track — files have no " + "declared home" + }) + + findings.has_drift = (len(findings.out_of_order) > 0 + or len(findings.orphans) > 0) + return findings +``` + +Properties worth calling out: + +- **No heuristics, no ML** — every finding traces to a specific row + (`Item.depends_on`, `Item.sequence_position`, `Track.depends_on`, or an + `Event` with `items_touched`/`files_touched`). +- **Idempotent and replayable** — because it's a pure function of + `(new_event, graph, event_log)`, `kt_check_drift` run standalone against + the full log produces the same findings the inline check would have + produced at the time, which is what makes it safe to re-audit history. +- **Append-only inputs** — the Event log is never mutated or deleted, so a + drift finding is always reproducible later for audit, and re-running drift + detection can never "erase" evidence of a past violation. + +--- + +## 6. Why This Cannot Become an Orchestrator by Accident + +**Tool classification:** + +| Read / advisory (no mutation of Track/Item/Project state) | Write (explicit, ID-scoped mutation) | +|---|---| +| `kt_get_project_status` | `kt_register_project` | +| `kt_list_tracks` | `kt_create_track` | +| `kt_get_track` | `kt_create_item` | +| `kt_get_next_steps` | `kt_record_session_summary` | +| `kt_check_drift`\* | `kt_record_decision` | +| `kt_render_roadmap` | `kt_update_item_status` | +| | `kt_sync_to_github` | +| | `kt_sync_to_linear` | + +\* `kt_check_drift` may persist a `drift_findings` row tied to the event it +inspected, but it never touches `Track`, `Item`, or assignment state — it +records an observation, not a decision. + +**Why `kt_get_next_steps` returning a ranked list is not dispatch:** + +- It computes a ranking (by `sequence_position`, unmet `depends_on` edges, + and current item status) and **returns it as data** in the tool result. + It does not write anything, does not call any other KnoTrack tool, and + does not notify, message, or invoke any agent, harness, or external + system. +- There is no tool, table, or field anywhere in the schema that represents + "this item is assigned to this agent/session." Nothing in the data model + can even express an assignment, so no code path could accidentally create + one. +- Because the protocol is the **stateless 2026-07-28 MCP spec**, KnoTrack + holds no session memory between calls. It cannot maintain an internal + work queue, cannot remember "I already told someone to do this," and has + no mechanism to push follow-up work — every call starts from zero and + ends when its response is returned. +- The server has **no scheduler, no cron, no webhook-out, no queue + consumer, and no outbound call that targets an agent**. Its only two + outbound integrations (`kt_sync_to_github`, `kt_sync_to_linear`) push + human-readable status to project-tracking *systems*, not instructions to + *agents* — and those, too, are explicit write tools a client must call, + not something the server triggers on its own. +- No tool calls another tool. The Tool Router dispatches exactly one tool + per client request to exactly one service method; there is no internal + "and then call X" chaining logic anywhere in the component diagram in + §2 — the only cross-module call inside a single request is + `kt_record_session_summary` invoking the Drift Detection Engine inline, + and that produces a report in the same response, not a new action. + +In short: orchestration requires the server to *initiate* action or *hold* +a plan of future action across calls. KnoTrack can do neither — it has no +memory between calls and no primitive that represents "do this next," +only one that represents "here is what I observe, ranked." + +--- + +## 7. Failure-Mode Notes + +### Postgres unreachable + +- The `pg` pool's health check fails; Fastify's `/health` endpoint reports + unhealthy so platform-level restarts/alerts (Render/Railway/Fly health + checks) can react. +- Every tool that touches the DB — which is all 14 — returns a structured + MCP tool error (JSON-RPC error object, HTTP 503) rather than crashing the + process or hanging the request. The pool is configured with a bounded + connection-acquire timeout so a request fails fast instead of queuing + indefinitely. +- Because the server holds no in-memory session state (stateless spec), a + failed write is simply a failed write — there is no queued mutation to + lose or corrupt, and nothing to reconcile on reconnect. The client (agent + harness) is responsible for retrying the same tool call, which is safe + because writes are scoped to explicit IDs and, for `kt_record_session_summary`, + guarded by a unique constraint on a client-supplied idempotency key so a + retried call cannot double-insert the same Event. +- No write tool partially commits: every multi-statement mutation + (see §3a) runs inside a single Postgres transaction, so an outage mid-way + through a transaction rolls back cleanly with nothing persisted. + +### An adapter call fails (GitHub or Linear unreachable/erroring) + +- `kt_sync_to_github` / `kt_sync_to_linear` wrap the external HTTP call with + a bounded retry (fixed attempt count, exponential backoff) inside the + Adapter Client, entirely separate from Postgres transactions. +- Adapter failure **never rolls back or blocks** core KnoTrack state — the + Project/Track/Item/Event/Decision tables are the source of truth and are + written (if at all) independently of adapter success. A sync attempt's + outcome (success, failed, retrying, last error) is recorded in a + `sync_log`/attempt row keyed by the target adapter and external ID + mapping, so failures are visible and auditable, not swallowed. +- The tool result surfaces the failure explicitly to the caller (it is not + silently retried forever or dropped); since `kt_sync_to_github`/ + `kt_sync_to_linear` are ordinary write tools, the calling agent can simply + invoke the same tool again later — sync is upsert-by-external-ID, so a + retry is idempotent rather than creating duplicate GitHub issues / Linear + tickets. +- Adapter credentials failing to decrypt or being revoked is treated the + same way: a scoped adapter error returned from that one tool call, with + no effect on any other tool. + +### Two agents call `kt_record_session_summary` concurrently for the same track + +This is the concurrency case that must not corrupt sequencing, and it is +handled with **Postgres row-level locking plus a serializable transaction**, +concretely: + +1. Both requests open a transaction (`BEGIN ISOLATION LEVEL SERIALIZABLE`). +2. Each transaction's first statement against the track is + `SELECT * FROM tracks WHERE id = $1 FOR UPDATE` — a row-level lock on the + specific track being reported on. Postgres's lock manager admits the + first transaction to acquire it and blocks the second at that statement. +3. The winning transaction inserts its `Event`, loads the dependency graph, + runs the Drift Detection Engine (§5) against the state as of that lock + acquisition, writes any `drift_findings`, and commits — releasing the + lock. +4. The second transaction, having been blocked at `FOR UPDATE`, now + proceeds against the **post-commit** state: it sees the first event + already in the log and any item-status transitions it caused. Its own + drift check therefore evaluates against up-to-date completion state, so + the two Events are effectively serialized end-to-end for that track — + never interleaved. +5. If Postgres instead raises a serialization failure (SQLSTATE `40001`, + possible if the two transactions also touch overlapping rows outside the + locked track, e.g. a shared cross-track dependency), the Event Service + catches that specific error code and retries the whole transaction once + or twice with jittered backoff before surfacing an error to the client — + the same pattern used for any other tool. +6. Because the lock scope is a single `track_id` row, concurrent session + summaries for **different** tracks are unaffected and proceed in + parallel — the design serializes exactly the contention that matters + (writers to the same track's sequencing) and nothing more. +7. Event ordering itself is derived from a `bigserial`/`created_at` column + assigned at insert time inside the lock, so "which event happened first" + for drift purposes is never ambiguous even under this concurrency. + +This requires no distributed lock manager or external coordination service: +a single Postgres instance already serializes correctly via native row +locks, which is sufficient because KnoTrack is single-tenant/self-hosted — +there is exactly one Postgres to coordinate against per installation. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..da7c4e7 --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,480 @@ +# KnoTrack Database Schema + +KnoTrack is a self-hosted, Postgres-backed MCP server. This document describes the +schema created by [`migrations/001_init.sql`](../migrations/001_init.sql) (reversed by +[`migrations/001_init.down.sql`](../migrations/001_init.down.sql)). + +- Engine: PostgreSQL 13+ +- Migration tool: `node-pg-migrate`, raw-SQL mode (`001_init.sql` / `001_init.down.sql` + is one up/down migration pair) +- Primary keys: `uuid`, generated with `gen_random_uuid()` (from the `pgcrypto` + extension, enabled by the migration) +- Timestamps: `timestamptz`, `created_at`/`updated_at` default to `now()` + +## Contents + +- [Entity-relationship diagram](#entity-relationship-diagram) +- [Cross-cutting decisions](#cross-cutting-decisions) + - [Enum vs. text + CHECK](#enum-vs-text--check) + - [Soft delete vs. hard delete for projects](#soft-delete-vs-hard-delete-for-projects) + - [Append-only tables](#append-only-tables) +- [Table reference](#table-reference) + +## Entity-relationship diagram + +```mermaid +erDiagram + PROJECTS ||--o{ ADAPTERS : "has" + PROJECTS ||--o{ TRACKS : "has" + PROJECTS ||--o{ EVENTS : "has" + PROJECTS ||--o{ DECISIONS : "has" + PROJECTS ||--o{ API_TOKENS : "scopes (nullable)" + PROJECTS ||--o{ DRIFT_FLAGS : "has" + + TRACKS ||--o{ ITEMS : "has" + TRACKS ||--o{ EVENTS : "tags (nullable)" + TRACKS ||--o{ DECISIONS : "tags (nullable)" + TRACKS ||--o{ DRIFT_FLAGS : "tags (nullable)" + TRACKS ||--o{ TRACK_DEPENDENCIES : "track_id" + TRACKS ||--o{ TRACK_DEPENDENCIES : "depends_on_track_id" + + ITEMS ||--o{ ITEM_DEPENDENCIES : "item_id" + ITEMS ||--o{ ITEM_DEPENDENCIES : "depends_on_item_id" + ITEMS ||--o{ DRIFT_FLAGS : "tags (nullable)" + + PROJECTS { + uuid id PK + text name + text source_type "CHECK github|linear|local" + text source_ref "nullable" + timestamptz created_at + timestamptz updated_at + timestamptz deleted_at "nullable, soft delete" + } + + ADAPTERS { + uuid id PK + uuid project_id FK + text type "CHECK github|linear" + bytea encrypted_credential + jsonb config + timestamptz created_at + } + + TRACKS { + uuid id PK + uuid project_id FK + text title + text status "CHECK, default on_track" + text source_doc_ref "nullable" + timestamptz created_at + timestamptz updated_at + } + + TRACK_DEPENDENCIES { + uuid track_id PK_FK + uuid depends_on_track_id PK_FK + timestamptz created_at + } + + ITEMS { + uuid id PK + uuid track_id FK + text title + integer sequence_position + text status "CHECK, default pending" + timestamptz created_at + timestamptz updated_at + } + + ITEM_DEPENDENCIES { + uuid item_id PK_FK + uuid depends_on_item_id PK_FK + timestamptz created_at + } + + EVENTS { + uuid id PK + uuid project_id FK + uuid track_id FK "nullable" + text summary_text + jsonb files_touched + jsonb items_touched + timestamptz created_at "append-only, no updated_at" + } + + DECISIONS { + uuid id PK + uuid project_id FK + uuid track_id FK "nullable" + text title + text rationale + text what_changed + timestamptz created_at "append-only, no updated_at" + } + + API_TOKENS { + uuid id PK + uuid project_id FK "nullable, server-wide if null" + text token_hash "unique, never the raw token" + text label "nullable" + timestamptz created_at + timestamptz last_used_at "nullable" + } + + DRIFT_FLAGS { + uuid id PK + uuid project_id FK + uuid track_id FK "nullable" + uuid item_id FK "nullable" + text kind "CHECK out_of_sequence|orphan_file_change" + jsonb detail + timestamptz raised_at + timestamptz resolved_at "nullable, null = open" + } +``` + +Notes on the diagram: + +- `TRACK_DEPENDENCIES` and `ITEM_DEPENDENCIES` are self-referential join tables (a + track/item can depend on another track/item of the same kind). Mermaid's `erDiagram` + can't natively draw a table referencing the *same* entity twice with different + meanings on a single relationship line, so each is shown as two labeled edges + (`track_id` and `depends_on_track_id`) into the same join table. +- Every edge from `PROJECTS`/`TRACKS`/`ITEMS` into a dependent table is drawn `||--o{` + (one-to-many, child side optional) because a project/track/item can have zero + matching child rows. + +## Cross-cutting decisions + +### Enum vs. text + CHECK + +Every enumerated column (`source_type`, `adapters.type`, `tracks.status`, +`items.status`, `drift_flags.kind`) is implemented as **`text` with a `CHECK` +constraint**, not a native Postgres `CREATE TYPE ... AS ENUM`. This choice is applied +consistently across the whole schema. Reasoning: + +- **Adding a new value is a plain, transaction-safe `ALTER TABLE ... DROP CONSTRAINT / + ADD CONSTRAINT`.** Adding a value to a native enum (`ALTER TYPE ... ADD VALUE`) could + not run inside the same transaction as other DDL on older Postgres versions (pre-12) + and still cannot be rolled back within the transaction that added it on any version — + a real hazard for a migration tool that wraps each migration in a transaction. +- **node-pg-migrate and most Postgres client libraries (`pg`, `node-postgres`) return + enum values as plain strings anyway**, so there's no type-safety loss in application + code — the CHECK constraint gives the same runtime guarantee at the database layer. +- **Simpler tooling story**: introspection, ORMs, and ad-hoc `psql`/GUI clients treat + `text` uniformly; native enums require special-casing in schema-diffing and + code-generation tools. +- The tradeoff accepted: a `CHECK` constraint doesn't restrict values already stored in + a column the way a `USING` cast to an enum type would, and it's marginally less + compact on disk (`text` vs. the 4-byte enum OID reference). Neither matters at + KnoTrack's expected scale (single self-hosted deployment per team). + +### Soft delete vs. hard delete for projects + +**Tension:** Every project-owned child table (`adapters`, `tracks`, `items`, `events`, +`decisions`, `api_tokens`, `drift_flags`) declares `project_id ... ON DELETE CASCADE`, +so that referential integrity is trivial to maintain and a hard `DELETE FROM projects +WHERE id = ...` never leaves orphaned rows. But `events` and `decisions` are explicitly +meant to be an **audit trail** — and a cascading hard delete would make that history +vanish irreversibly along with the project, which defeats the point of keeping it. + +**Resolution:** `projects` has a nullable `deleted_at timestamptz` column. +KnoTrack's application code treats "deleting a project" as `UPDATE projects SET +deleted_at = now() WHERE id = ...`, never as a hard `DELETE`, in normal operation: + +- All read paths (`kt_get_project_status` and friends) filter `WHERE deleted_at IS + NULL` (a partial index, `idx_projects_not_deleted`, keeps that filter cheap). +- Audit history (events, decisions) survives a project's soft delete unconditionally, + because no row is ever removed — it just becomes unreachable through the normal + "list active projects" path. +- A soft-deleted project can be restored by clearing `deleted_at`, with its full + history intact. +- The `ON DELETE CASCADE` foreign keys still exist and still work — they are the + correct behavior for the *rare, deliberate* hard delete: an admin script or an + operator satisfying a legal erasure request (e.g. GDPR Article 17), where actually + destroying the audit trail is the intended, informed outcome, not an accident of + routine project cleanup. That path is intentionally not exposed as a normal MCP tool + call. + +In short: **the schema is built to support hard delete (for the rare case where it's +truly wanted), but the application layer never uses it for routine deletion** — routine +deletion is soft, via `deleted_at`. + +### Append-only tables + +`events` and `decisions` are append-only by convention: KnoTrack's application code +never issues `UPDATE` against these tables (only `INSERT` and `SELECT`), which is why +neither table has an `updated_at` column — there is nothing to represent, and a +present-but-always-null `updated_at` would misleadingly imply mutability. + +This is enforced by convention plus code review, not by the schema itself, because the +default `node-pg-migrate`-managed role needs `UPDATE` for the rest of the schema and +Postgres privileges are granted per-table, not per-statement-in-application-code. For a +deployment that wants the invariant enforced at the database level (e.g. to limit the +blast radius of a bug or a compromised application credential), revoke `UPDATE` on +these two tables from the role the application connects as: + +```sql +REVOKE UPDATE ON events, decisions FROM knotrack_app; +``` + +(Substitute your actual application role name. This statement is documented here and +left commented out in `001_init.sql` rather than executed unconditionally, since the +role name is deployment-specific and the statement would fail the migration on a fresh +database where that role doesn't exist yet.) `INSERT` and `SELECT` remain granted; only +`UPDATE` is revoked. `DELETE` is a separate privilege and is out of scope for this +invariant — cascading deletes from a hard project delete still need it. + +## Table reference + +### `projects` + +The top-level entity: one row per tracked codebase/initiative. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `name` | `text` | `NOT NULL` | | +| `source_type` | `text` | `NOT NULL`, `CHECK IN ('github','linear','local')` | See [Enum vs. text + CHECK](#enum-vs-text--check) | +| `source_ref` | `text` | nullable | Repo URL, Linear project ID, or local filesystem path, depending on `source_type` | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | +| `updated_at` | `timestamptz` | `NOT NULL DEFAULT now()` | Auto-maintained by `trg_projects_set_updated_at` | +| `deleted_at` | `timestamptz` | nullable | `NULL` = active. See [Soft delete](#soft-delete-vs-hard-delete-for-projects) | + +**Indexes:** `idx_projects_not_deleted` — partial index on `(id) WHERE deleted_at IS +NULL`, backing the "active projects" filter every read path applies. + +### `adapters` + +Zero or more per project; each adapter connects the project to an external source +(GitHub, Linear) for pulling/pushing state. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | `NOT NULL`, FK → `projects.id`, `ON DELETE CASCADE` | | +| `type` | `text` | `NOT NULL`, `CHECK IN ('github','linear')` | | +| `encrypted_credential` | `bytea` | `NOT NULL` | Ciphertext of the PAT/API key; encryption/decryption happens in application code, never in SQL. The database never sees a plaintext credential. | +| `config` | `jsonb` | `NOT NULL DEFAULT '{}'` | e.g. `{"owner": "acme", "repo": "widgets"}` for GitHub, `{"team_id": "..."}` for Linear | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | + +**Constraints:** `uq_adapters_project_type` — `UNIQUE (project_id, type)`: at most one +adapter of a given type per project. + +**Indexes:** `idx_adapters_project_id` on `(project_id)`. + +**ON DELETE reasoning:** `CASCADE` — an adapter has no meaning independent of its +project; if the project is (hard-)deleted, its adapters (and their encrypted +credentials) should go with it. Adapters carry no audit-trail role, so cascading here +doesn't touch the tension discussed above. + +### `tracks` + +A track is a coherent unit of work within a project (roughly: an epic/initiative). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | `NOT NULL`, FK → `projects.id`, `ON DELETE CASCADE` | | +| `title` | `text` | `NOT NULL` | | +| `status` | `text` | `NOT NULL DEFAULT 'on_track'`, `CHECK IN ('on_track','pivot_pending','blocked','done')` | | +| `source_doc_ref` | `text` | nullable | e.g. a design doc URL or Linear project/issue reference the track was derived from | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | +| `updated_at` | `timestamptz` | `NOT NULL DEFAULT now()` | Auto-maintained by `trg_tracks_set_updated_at` | + +**Indexes:** `idx_tracks_project_id` on `(project_id)`. + +**ON DELETE reasoning:** `CASCADE` from `projects` — a track cannot outlive its +project. (A track *itself* being deleted independently of its project is handled +gracefully by `items`/`events`/`decisions`/`drift_flags` below via `SET NULL`, not +`CASCADE`, where those tables are audit trail.) + +### `track_dependencies` + +Models "track A can't be considered done/unblocked until track B is" as a directed +edge. Composite primary key; no surrogate `id`, since the pair *is* the identity of the +row. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `track_id` | `uuid` | PK (composite), FK → `tracks.id`, `ON DELETE CASCADE` | The dependent track | +| `depends_on_track_id` | `uuid` | PK (composite), FK → `tracks.id`, `ON DELETE CASCADE` | The prerequisite track | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | + +**Constraints:** `ck_track_dependencies_no_self_dep` — `CHECK (track_id <> +depends_on_track_id)`, preventing a track from depending on itself at the row level. + +**Cycle prevention:** A `CHECK` constraint can only see the row being inserted, so it +can block direct self-dependency (`A → A`) but **cannot** detect or prevent a +multi-hop cycle (`A → B → C → A`). Cycle detection across the whole dependency graph is +the application's responsibility (a graph walk before insert, or a periodic +consistency check) — this is a deliberate limitation of the schema, not an oversight. + +**Indexes:** the composite PK already indexes `(track_id, depends_on_track_id)` (and +therefore serves "what does track X depend on" lookups). `idx_track_dependencies_depends_on` +on `(depends_on_track_id)` additionally serves the reverse direction — "what depends on +track Y" — needed when a track's status changes and dependents must be notified/re-evaluated. + +### `items` + +An item is a step within a track — the unit of sequenced, actionable work. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `track_id` | `uuid` | `NOT NULL`, FK → `tracks.id`, `ON DELETE CASCADE` | | +| `title` | `text` | `NOT NULL` | | +| `sequence_position` | `integer` | `NOT NULL` | Defines ordering within the track. Not declared `UNIQUE` per track: reordering is a common operation and application code is expected to renumber/compact positions rather than rely on the database to reject duplicates, which would make reordering multi-step and race-prone. | +| `status` | `text` | `NOT NULL DEFAULT 'pending'`, `CHECK IN ('pending','in_progress','done','blocked')` | | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | +| `updated_at` | `timestamptz` | `NOT NULL DEFAULT now()` | Auto-maintained by `trg_items_set_updated_at` | + +**Indexes:** +- `idx_items_track_id` on `(track_id)`. +- `idx_items_track_id_sequence_position` on `(track_id, sequence_position)` — the + "fetch this track's items in order" query is common (rendering a track, computing + the next actionable item) and this composite index serves it directly without a sort. + +**ON DELETE reasoning:** `CASCADE` from `tracks` — an item has no meaning independent +of its track. + +### `item_dependencies` + +Same shape and reasoning as `track_dependencies`, one level down: item A can't start +until item B is done. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `item_id` | `uuid` | PK (composite), FK → `items.id`, `ON DELETE CASCADE` | The dependent item | +| `depends_on_item_id` | `uuid` | PK (composite), FK → `items.id`, `ON DELETE CASCADE` | The prerequisite item | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | + +**Constraints:** `ck_item_dependencies_no_self_dep` — `CHECK (item_id <> +depends_on_item_id)`. Same multi-hop-cycle caveat as `track_dependencies`: full cycle +prevention is application-level. + +**Indexes:** composite PK covers `(item_id, depends_on_item_id)`; +`idx_item_dependencies_depends_on` on `(depends_on_item_id)` covers the reverse +direction. + +### `events` + +**Append-only** audit-trail row recording an observed change (e.g. "adapter poll found +these files changed and these items touched"). See +[Append-only tables](#append-only-tables). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | `NOT NULL`, FK → `projects.id`, `ON DELETE CASCADE` | | +| `track_id` | `uuid` | nullable, FK → `tracks.id`, `ON DELETE SET NULL` | An event may not be attributable to a single track | +| `summary_text` | `text` | `NOT NULL` | Human-readable summary, typically model-generated | +| `files_touched` | `jsonb` | `NOT NULL DEFAULT '[]'` | Array of file path strings | +| `items_touched` | `jsonb` | `NOT NULL DEFAULT '[]'` | Array of item `id` strings | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | No `updated_at` — see [Append-only tables](#append-only-tables) | + +**Indexes:** `idx_events_project_id` on `(project_id)`, `idx_events_track_id` on +`(track_id)`. + +**ON DELETE reasoning — the tension called out explicitly:** `project_id` is +`CASCADE` (see [Soft delete](#soft-delete-vs-hard-delete-for-projects) for why that's +safe in practice: routine project deletion is soft, so this cascade only fires on a +deliberate hard delete). `track_id`, by contrast, is `SET NULL`, not `CASCADE` — +deleting an individual track (independent of its project) is a normal, expected +operation, and doing so must not silently destroy event history. The event survives +with `track_id = NULL`, still attached to its project. + +`files_touched`/`items_touched` are `jsonb` arrays rather than normalized join tables +(e.g. an `event_files` table) because they are immutable, write-once, and read as a +whole alongside the event — normalizing them would add join overhead for a access +pattern that never filters or aggregates by individual file/item across events at the +SQL layer (that kind of query, if ever needed, is expected to go through +`items_touched`'s item IDs against the `items` table, not the raw JSON). + +### `decisions` + +**Append-only** audit-trail row recording a deliberate decision (e.g. "we chose to +pivot track X because Y"). Structurally near-identical to `events`; kept as a separate +table because a decision has different fields (`rationale`, `what_changed`) and a +different semantic weight (deliberate/curated vs. observed/automatic) — collapsing +them into one polymorphic table would blur that distinction in queries and in the tool +surface (`kt_record_event` vs. `kt_record_decision`). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | `NOT NULL`, FK → `projects.id`, `ON DELETE CASCADE` | | +| `track_id` | `uuid` | nullable, FK → `tracks.id`, `ON DELETE SET NULL` | Same reasoning as `events.track_id` | +| `title` | `text` | `NOT NULL` | | +| `rationale` | `text` | nullable | Why the decision was made | +| `what_changed` | `text` | nullable | What concretely changed as a result | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | No `updated_at` — append-only | + +**Indexes:** `idx_decisions_project_id` on `(project_id)`, `idx_decisions_track_id` on +`(track_id)`. + +**ON DELETE reasoning:** identical to `events` — `CASCADE` on `project_id`, `SET NULL` +on `track_id`. + +### `api_tokens` + +Bearer tokens for authenticating MCP clients against this KnoTrack server. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | nullable, FK → `projects.id`, `ON DELETE CASCADE` | `NULL` = server-wide token (valid across all projects); non-null = scoped to that one project | +| `token_hash` | `text` | `NOT NULL`, `UNIQUE` | A hash (e.g. SHA-256) of the bearer token — **never** the raw token itself. The raw token is shown to the operator exactly once at creation time and is not recoverable from the database. | +| `label` | `text` | nullable | Free-text description of which client/device/CI job holds this token | +| `created_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | +| `last_used_at` | `timestamptz` | nullable | Updated by application code on successful auth; `NULL` means never used | + +**Constraints:** `uq_api_tokens_token_hash` — `UNIQUE (token_hash)`, which also +provides the index backing every authentication lookup (`SELECT ... WHERE token_hash = +$1`), the hottest query this table serves. + +**Indexes:** `idx_api_tokens_project_id` on `(project_id)`, for the "list a project's +tokens" admin view. + +**ON DELETE reasoning:** `CASCADE` on `project_id` when set — a project-scoped token +has no purpose once its project is gone. Server-wide tokens (`project_id IS NULL`) are +unaffected by any project's deletion, by construction. + +### `drift_flags` + +Flags raised automatically when observed reality (from adapter polling) diverges from +tracked state — e.g. a file changed that isn't linked to any known item +(`orphan_file_change`), or work happened on an item out of its declared sequence +(`out_of_sequence`). + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| `id` | `uuid` | PK, default `gen_random_uuid()` | | +| `project_id` | `uuid` | `NOT NULL`, FK → `projects.id`, `ON DELETE CASCADE` | | +| `track_id` | `uuid` | nullable, FK → `tracks.id`, `ON DELETE SET NULL` | | +| `item_id` | `uuid` | nullable, FK → `items.id`, `ON DELETE SET NULL` | | +| `kind` | `text` | `NOT NULL`, `CHECK IN ('out_of_sequence','orphan_file_change')` | | +| `detail` | `jsonb` | `NOT NULL DEFAULT '{}'` | Structured detail specific to `kind` (e.g. the offending file path, or the expected vs. actual sequence position) | +| `raised_at` | `timestamptz` | `NOT NULL DEFAULT now()` | | +| `resolved_at` | `timestamptz` | nullable | `NULL` = still open. Set once a human or automated process resolves the flag. | + +**Indexes:** +- `idx_drift_flags_project_id` on `(project_id)` +- `idx_drift_flags_track_id` on `(track_id)` +- `idx_drift_flags_item_id` on `(item_id)` +- `idx_drift_flags_open_by_project` — **partial** index on `(project_id) WHERE + resolved_at IS NULL`. This is the hot path: `kt_get_project_status` needs "open + drift flags for project X" on effectively every call. A partial index only covers + currently-open flags, so it stays small and fast even as a long-lived project + accumulates thousands of historically-resolved flags — the full-table index on + `project_id` would keep growing and get progressively less selective for this exact + query, while the partial index's size tracks only the (much smaller, bounded-in- + practice) count of currently-open flags. + +**ON DELETE reasoning:** `track_id`/`item_id` are `SET NULL` (not `CASCADE`) for the +same audit-trail-preservation reason as `events`/`decisions`: a drift flag, especially +a *resolved* one, is a historical record; deleting the track or item it pointed at +shouldn't delete the record that drift was ever detected there. `project_id` is +`CASCADE`, consistent with every other project-owned child table (and, as elsewhere, +safe in practice because routine project removal is soft-delete, not hard-delete). diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..647b35a --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,581 @@ +# KnoTrack — Product Requirements Document + +**Version:** 1.0 +**Status:** Approved for implementation +**Date:** 2026-08-23 +**Owner:** KnoTrack maintainers + +This document is written to be implemented with zero follow-up questions. Every place that would normally be marked "TBD" or "needs discussion" has instead been resolved to a specific decision, with a one-line rationale. If a future implementer disagrees with a decision, that is a v2 proposal, not an ambiguity in v1. + +--- + +## Table of Contents + +1. [Problem Statement](#1-problem-statement) +2. [Goals and Non-Goals](#2-goals-and-non-goals) +3. [Target Users / Personas](#3-target-users--personas) +4. [Functional Requirements](#4-functional-requirements) +5. [Non-Functional Requirements](#5-non-functional-requirements) +6. [Success Metrics](#6-success-metrics) +7. [Out of Scope for v1](#7-out-of-scope-for-v1) +8. [Glossary](#8-glossary) +9. [Appendix: Data Model Reference](#9-appendix-data-model-reference) + +--- + +## 1. Problem Statement + +### 1.1 Why project drift happens + +When a human works alone, they hold the plan in their head and notice, intuitively, when they've wandered off it. When an AI coding agent (or several, across several tools) works on the same project, that intuition disappears: + +- **Every agent session starts cold.** A fresh Claude Code, Cowork, Windsurf, or Codex CLI session has no memory of what the last session decided, why, or what was explicitly deferred. It re-derives context from whatever files it happens to read. +- **The plan lives in prose, not structure.** Roadmaps, specs, and ticket descriptions are read by agents as unstructured text. "Do X before Y" is a sentence, not a constraint the agent's next action is checked against. +- **Multiple tools touch one project.** A solo developer might plan in Linear, implement with Claude Code, and patch a bug with Windsurf the same afternoon. None of these tools share a notion of "what already happened" unless something explicitly keeps a cross-tool record. +- **Nobody re-reads the roadmap once work starts.** The plan document is a snapshot from the start of a track. Actual work drifts from it silently — a dependency gets skipped because it's inconvenient, or a file gets touched that has nothing to do with the declared piece of work — and nothing flags it because no one is comparing "what was declared" against "what actually happened." + +The result: by the time a human looks up, the roadmap is fiction, the sequencing has been violated in ways nobody decided on purpose, and there is no record of *when* or *why* it diverged — only a diff between the doc and the code that nobody can explain. + +### 1.2 Why existing orchestrators don't solve this + +Orchestration frameworks (multi-agent dispatchers, task-queue systems, autonomous "agent swarm" runners) solve a different problem: *getting work done by assigning it to agents and sequencing their execution*. They are necessarily prescriptive about how work is executed. That is precisely what makes them a poor fit for status, sequencing-advice, and drift detection as a *general-purpose, cross-tool* capability: + +- An orchestrator is normally single-harness (built into or bolted onto one specific agent runtime). It has no reason to also work identically inside Windsurf, LM Studio, Goose, or Hermes — its job ends at the harness boundary. +- An orchestrator's "status" view is a projection of its own dispatch queue, not an independent read of a project's source-of-truth documents. If the orchestrator didn't dispatch the work, it usually doesn't know it happened. +- Orchestrators treat drift as a scheduling problem to *prevent* (by controlling execution order), not as a *reporting* problem to surface after the fact for a human or another system to act on. A project that already has a working orchestrator does not need another system fighting it for control of execution order. +- None of the orchestration tools surveyed treat "drift" as a first-class, structurally-computed, auditable record — it is usually implicit in whatever the scheduler happens to have queued. + +KnoTrack is deliberately the complement, not the competitor: it is a **read-mostly, advice-only** layer that any agent harness can call into over MCP, that never takes control of execution, and that is equally at home sitting next to a project with no orchestrator (its default mode) and a project that already has one (where KnoTrack's `kt_get_next_steps` output becomes one more input the orchestrator's human owner can consult — KnoTrack never talks to the orchestrator, dispatches to it, or expects it to exist). + +### 1.3 How KnoTrack actually gets plan data in (important scope clarification) + +KnoTrack does **not** parse arbitrary roadmap/spec file formats out of a local folder. There is no "point KnoTrack at a directory and it figures out the plan" tool in v1. Concretely: + +- **Local-folder projects:** the calling agent (which already has filesystem access — that's what Claude Code, Windsurf, etc. are for) reads the project's roadmap/spec/ticket files itself, and populates KnoTrack's structured model by calling `kt_create_track` / `kt_create_item`. KnoTrack is the structured record the agent writes into, not a document parser. +- **GitHub-backed projects:** `kt_sync_to_github` provides structured import of Issues into Items via the GitHub API — no free-text parsing involved. +- **Linear-backed projects:** `kt_sync_to_linear` does the same against the Linear API. + +This is a deliberate v1 boundary, not an oversight: building a robust free-text roadmap parser is a large, format-specific problem with poor reliability, and it is unnecessary work when the calling agent can already read the file and make two structured tool calls. See §7 for the formal scope statement. + +--- + +## 2. Goals and Non-Goals + +### 2.1 Goals + +- G1. Give any AI agent, in any MCP-capable harness, a single, structured place to ask "what is the current status of this project" and get the same answer regardless of which harness is asking. +- G2. Give any AI agent a deterministic, explainable, advisory ranking of "what unblocked work exists next," without ever assigning or executing that work. +- G3. Detect drift — sequencing violations and untracked work — **structurally**, from an append-only event log compared against the declared plan, not from an agent's self-report. +- G4. Keep an explicit, human-readable audit trail of intentional pivots (Decisions) separate from the plan itself, so "we meant to do this" is always distinguishable from "this just happened." +- G5. Work identically across heterogeneous MCP clients (Claude Code/Cowork, Windsurf, Codex CLI, LM Studio, Goose, Hermes, and others) by targeting the MCP 2026-07-28 stateless spec and never relying on server-side session memory. +- G6. Be trivially self-hostable by a single developer with no ops background, on at least one genuinely free path, in under 30 minutes. +- G7. Support, not replace, a project's existing orchestrator if one exists — KnoTrack has no concept of "the" orchestrator and does not attempt to detect, integrate with, or gate one. + +### 2.2 Non-Goals ("KnoTrack will never...") + +- KnoTrack will never assign, dispatch, trigger, or execute work on behalf of any agent. `kt_get_next_steps` returns a ranked *recommendation*; nothing in the system calls out to an agent, a CI system, or a queue. +- KnoTrack will never require or assume a specific agent harness. Any MCP 2026-07-28-compliant client is a first-class citizen. +- KnoTrack will never infer a Decision (an intentional pivot) from a boolean flag or from silence. A Decision is only ever an explicit, human/agent-authored record with rationale text. +- KnoTrack will never mutate or delete an Event or a Decision once written. The event log and decision log are append-only for the lifetime of the project. +- KnoTrack will never hand-edit `ROADMAP.md`, and will never treat it as an input. It is a rendered, disposable projection of the database, fully overwritten on every render. +- KnoTrack will never run as a shared multi-tenant service operated by the maintainers. Every installer owns their own database and server; the maintainers have no visibility into any installer's data, ever. +- KnoTrack will never phone home. No usage analytics or telemetry leave a self-hosted instance to the maintainers, by design. +- KnoTrack will never block a status update because it looks out of sequence. It will warn; it will not refuse. The human/agent is always the final authority over their own actions. +- KnoTrack will never store adapter credentials (GitHub PAT, Linear API key) anywhere reachable from an MCP client. They live server-side only. +- KnoTrack will never claim compatibility it has not actually verified the way it claims to have verified it (see §5.4 for exactly how each client's compatibility was established). + +--- + +## 3. Target Users / Personas + +### 3.1 The solo multi-tool developer ("Priya") + +Runs three side projects. Plans in a plain Markdown roadmap file in each repo. Uses Claude Code for scaffolding, Windsurf for UI work, and occasionally Codex CLI for quick scripts — often switching mid-project depending on which is fastest for the task at hand. Priya's core pain: every time she switches tools, the new session has no idea what the last tool did, and she has caught herself re-doing work and, once, shipping a feature whose declared dependency wasn't actually finished. She wants one status view that all three tools update and read from, and a nagging-but-not-blocking warning when something is done out of order. + +**What KnoTrack gives Priya:** register each repo as a Project once; every harness gets the same bearer token in its own MCP config; `kt_get_project_status` and `kt_get_next_steps` give her (and her agents) a consistent view no matter which tool she opens. + +### 3.2 The small team (2–6 developers, one shared backend) + +A small team sharing one Linear workspace and one GitHub repo, each developer running their own preferred agent harness against a shared KnoTrack instance that one of them deployed. Their pain: Linear tickets say one thing, but two developers' agents have started overlapping work because neither's agent checked what the other's session already touched. They want a shared, structural drift signal that isn't just "did you remember to update the ticket." + +**What KnoTrack gives them:** one shared self-hosted instance (single Postgres database, single server) with one bearer token issued per developer's device; `kt_record_session_summary` after every session gives a shared Event log; `kt_check_drift` gives a structural, not self-reported, view of whether anyone stepped out of declared sequence; `kt_sync_to_linear` keeps Items lined up with the team's actual tickets. + +### 3.3 The open-source installer who is not the original author + +Found KnoTrack on GitHub, is not a KnoTrack contributor, and just wants to run it for their own unrelated project. They have no interest in reading the source. Their pain: most self-hosted OSS tools either require Docker/Kubernetes expertise or turn out to have a hidden paid dependency once you're three steps into setup. + +**What KnoTrack gives them:** three documented, tested deploy paths (Render+Supabase, Railway+Postgres, Fly.io) with the real cost/limitation of each stated up front (§5.5), an Apache 2.0 license with a NOTICE file so they know exactly what attribution is required, and a setup path that ends in a working bearer token and a first `kt_register_project` call with no undocumented step in between. + +--- + +## 4. Functional Requirements + +### 4.0 Conventions used throughout this section + +- All 14 tools are exposed over MCP following the **2026-07-28 stateless MCP spec**: every call is self-contained and includes every ID it needs (`project_id`, and further-scoped IDs as applicable). No tool relies on "the last project you registered," "the current track," or any other server-side session memory — a stateless server has none to rely on, and KnoTrack's implementation must not simulate it via in-memory globals either, since MCP clients may (and do) round-robin calls across reconnecting transports. +- IDs are plain UUIDs (`gen_random_uuid()`, no prefix) — see `docs/DATABASE_SCHEMA.md` for the canonical column definitions. +- All tool inputs are validated against a JSON Schema with `additionalProperties: false`. Unknown fields are rejected, not ignored — this catches client-side typos immediately instead of silently dropping data. +- All tool outputs are returned as a single JSON object inside the MCP tool result's text content block. +- Errors are structured: `{ "code": "", "message": "", "details": { ... } }`. Defined codes used across tools: `VALIDATION`, `NOT_FOUND`, `CONFLICT`, `CYCLE_DETECTED`, `ADAPTER_NOT_CONFIGURED`, `UPSTREAM_ERROR`, `UNAUTHORIZED`. +- `project_id` is a required input on every tool except `kt_register_project`. Passing a `track_id`, `item_id`, `event_id`, or `decision_id` that exists but does not belong to the given `project_id` is always a `NOT_FOUND` error (scoped lookup, not global lookup) — this prevents one project's IDs from ever being usable to read or write another project's data, which matters once a single instance hosts more than one project. +- No tool call is retried automatically by the server; MCP clients are responsible for their own retry policy. All writes are wrapped in a single database transaction, so a failed call never leaves partial rows (see §5.2). +- Pagination: v1 does not paginate `kt_list_tracks` or track/item listings — the realistic scale for one project's structured plan (tens of tracks, hundreds of items) does not need it. The one genuinely unbounded log, Events, is controlled instead by a `since` timestamp (on `kt_check_drift`) and a hard cap (`event_limit`, max 100, on `kt_get_project_status`) rather than cursor pagination — this is deliberately simpler than pagination for a dataset this shape, and is a stated v1 scope decision, not an oversight. + +### 4.1 `kt_register_project` + +**Description:** Registers a Project, or upserts one on `(source_type, source_ref)` — calling it again with the same pair updates the existing row (name, adapters) and returns the *original* `project_id` rather than erroring or creating a duplicate. This is also the only mechanism to add or rotate adapter credentials after initial registration, since credentials are supplied directly in the call rather than read from server-side configuration. + +**Inputs:** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `name` | string, 1–200 chars | yes | Display name. Not required to be unique — uniqueness is on `(source_type, source_ref)`, not `name` (see Business rules). | +| `source_type` | enum: `"github" \| "linear" \| "local"` | yes | What kind of source `source_ref` identifies. | +| `source_ref` | string, 1–500 chars | yes | Repo URL, Linear project ID, or local filesystem path, depending on `source_type`. | +| `adapters` | object `{ github?, linear? }` | no | Per-adapter credentials, supplied directly in the call (not read from server env vars). `github: { personal_access_token, repo? }`; `linear: { api_key, team_id }`. | + +**Output:** `{ project_id }` + +**Business rules / edge cases:** +- Uniqueness is enforced on `(source_type, source_ref)`, not on `name`. Calling again with the same `(source_type, source_ref)` pair upserts: name and any supplied adapter credentials are updated on the existing row, and the call returns that row's original `project_id` — never a `CONFLICT`, never a duplicate project. +- Credentials in `adapters.github`/`adapters.linear` are encrypted (AES-256-GCM, §5) before being persisted to the separate `adapter_credentials` table; they are never echoed back in this or any other tool's output. +- If encrypting or persisting a supplied adapter credential fails (e.g. a crypto/database error), the whole call fails with a generic `500 INTERNAL_ERROR` rather than partially succeeding — this is a hard failure, not a soft-fail-with-warnings path. There is no "requested an adapter with no credential configured" case, since credentials are supplied inline on the call rather than resolved from server-side configuration. + +**Acceptance criteria:** +- **Given** no project exists with `source_type: "github", source_ref: "acme/widgets"`, **when** `kt_register_project` is called with `name: "Acme API", source_type: "github", source_ref: "acme/widgets"`, **then** the call succeeds and returns a new `project_id`. +- **Given** a project already exists with `source_type: "github", source_ref: "acme/widgets"`, **when** `kt_register_project` is called again with the same `source_type`/`source_ref` and a different `name`, **then** the call succeeds, the existing row's `name` is updated, and the same `project_id` as before is returned. +- **Given** a valid `adapters.github.personal_access_token` is supplied, **when** `kt_register_project` is called, **then** the call succeeds and the token is stored encrypted in `adapter_credentials`, never appearing in the tool's output or in any other tool's output. + +### 4.2 `kt_get_project_status` + +**Description:** The primary "what's going on with this project" overview call. + +**Inputs:** `project_id` (required), `event_limit` (integer, optional, default 10, max 100). + +**Output:** +``` +{ + project: { project_id, name, root_path, repo_url, adapters_enabled }, + tracks: [ { track_id, title, status, item_counts: { total, not_started, in_progress, blocked, done } } ], + drift_flags: [ ...same shape as kt_check_drift's finding entries... ], + drift_last_checked_at: "", + recent_events: [ { event_id, track_id, summary, files_touched, self_reported_drift, created_at } ] // most recent first, capped at event_limit +} +``` + +**Business rules / edge cases:** +- `drift_flags` and `drift_last_checked_at` reflect the **cached result of the most recent `kt_check_drift` or `kt_record_session_summary` call** for this project — this call does **not** itself recompute drift. *Rationale: recomputing structural drift on every status read would make the single most frequently called tool also the most expensive one; freshness is bounded and visible instead via `drift_last_checked_at`, and any caller who needs a guaranteed-fresh answer calls `kt_check_drift` directly.* If neither has ever been called for this project, `drift_flags` is `[]` and `drift_last_checked_at` is `null`. +- A project with zero tracks returns `tracks: []` — this is valid, not an error. +- `project_id` not found → `NOT_FOUND`. + +**Acceptance criteria:** +- **Given** a project with 3 tracks and no drift check has ever run, **when** `kt_get_project_status` is called, **then** `drift_flags` is `[]` and `drift_last_checked_at` is `null`. +- **Given** `kt_check_drift` was run 2 hours ago and found 1 sequence-drift item, **when** `kt_get_project_status` is called now (with no new drift check run in between), **then** `drift_flags` contains that same 1 finding and `drift_last_checked_at` equals the timestamp of that earlier check, not "now." +- **Given** an unknown `project_id`, **when** `kt_get_project_status` is called, **then** the call fails with `NOT_FOUND`. + +### 4.3 `kt_list_tracks` + +**Description:** List a project's tracks, optionally filtered by status. + +**Inputs:** `project_id` (required), `status` (optional enum: `on_track | pivot_pending | blocked | done`), `include_items` (boolean, optional, default `false`). + +**Output:** `{ tracks: [ { track_id, title, status, depends_on: [track_id...], items?: [...] } ] }` — `items` present only when `include_items: true`, in `sequence_position` order. + +**Business rules / edge cases:** +- Invalid `status` value → `VALIDATION` naming the four allowed values. +- No tracks match the filter → `{ tracks: [] }`, not an error. + +**Acceptance criteria:** +- **Given** a project with 2 `on_track` and 1 `blocked` track, **when** `kt_list_tracks` is called with `status: "blocked"`, **then** exactly 1 track is returned. +- **Given** `status: "in_progress"` (not a valid Track status — that's an Item status), **when** `kt_list_tracks` is called, **then** the call fails with `VALIDATION`. +- **Given** `include_items: true`, **when** `kt_list_tracks` is called, **then** each returned track includes its `items` array ordered ascending by `sequence_position`. + +### 4.4 `kt_get_track` + +**Description:** Full detail for one track: its items and the resolved dependency graph. + +**Inputs:** `project_id` (required), `track_id` (required). + +**Output:** +``` +{ + track: { track_id, title, description, status, depends_on: [track_id...] }, + items: [ { item_id, title, status, sequence_position, depends_on: [item_id...], file_patterns: [...] } ], + dependency_graph: { + track_edges: [ { from: track_id, to: track_id } ], // "from depends_on to" + item_edges: [ { from: item_id, to: item_id } ] + }, + dangling_dependencies: [ { item_id, invalid_dependency_id } ] +} +``` + +**Business rules / edge cases:** +- `track_id` must belong to `project_id`; if it belongs to a different project or doesn't exist, `NOT_FOUND`. +- Any `depends_on` reference (track- or item-level) that points at an ID no longer resolvable is **not** a hard failure for the whole call — it is collected into `dangling_dependencies` (or the track-level equivalent) so the rest of the track's real data is still usable. *Rationale: since v1 has no delete tool for tracks/items, dangling references should be rare, but a typo'd ID at creation time (§4.6/§4.7) should degrade gracefully on read rather than making the whole track unreadable.* +- `item_edges` in `dependency_graph` are always within this track: `kt_create_item` (§4.7) validates that every `depends_on` id belongs to the same track as the item being created, so an item can never declare a dependency on an item in a different track. Item-level dependencies are still independent of track-level dependencies within the track (an item's `depends_on` is not required to mirror the track's own `depends_on`). + +**Acceptance criteria:** +- **Given** track T has 4 items in sequence positions 1, 2, 3, 4, **when** `kt_get_track` is called, **then** `items` is returned in that exact order. +- **Given** item A (in track T) declares `depends_on: [B]` where B is a different item also in track T, **when** `kt_get_track` is called for T, **then** `dependency_graph.item_edges` includes `{ from: A, to: B }`. +- **Given** an item's `depends_on` array contains an ID that does not exist in the database, **when** `kt_get_track` is called, **then** the call still succeeds, and `dangling_dependencies` contains that pair. +- **Given** a `track_id` that exists but belongs to a different `project_id` than the one supplied, **when** `kt_get_track` is called, **then** the call fails with `NOT_FOUND`. + +### 4.5 `kt_get_next_steps` + +**Description:** The advisory ranking tool. Returns unblocked (or newly-unblocked) items in a deterministic priority order with a stated rationale for each. **This tool never assigns, dispatches, or executes anything, and it is the one guarantee in this document that most directly defines what KnoTrack is not.** + +**Inputs:** `project_id` (required), `track_id` (optional — scope to one track; omitted means whole project), `limit` (integer, optional, default 5, max 25), `include_blocked_tracks` (boolean, optional, default `false`). + +**Output:** +``` +{ + advisory_notice: "This is a ranked recommendation only. KnoTrack does not assign, dispatch, or execute this work. The calling agent or human decides what to do next.", + ranked_items: [ + { + item_id, track_id, title, + unblocked: true, + blocking_dependencies: [], + rationale: "All declared dependencies are done; track is on_track; next in declared sequence (position 3)." + } + ], + blocking_summary: [ { item_id, title, blocking_dependencies: [item_id...] } ] // present when ranked_items is empty +} +``` + +**Ranking algorithm (deterministic, must be reproduced exactly):** +1. Exclude all items whose track's status is `blocked` or `done`, unless `include_blocked_tracks: true`. +2. Among remaining items with status `not_started` or `blocked`, compute `unblocked` = true iff every ID in the item's `depends_on` refers to an item with status `done`. +3. Sort by: (a) track status priority — `on_track` before `pivot_pending`; (b) `unblocked: true` before `unblocked: false`; (c) `sequence_position` ascending as the final tie-break. +4. Truncate to `limit`. +5. `rationale` is generated from the same facts used to sort (track status, unblocked state, sequence position) — it is not a free-form LLM summary, it is a templated sentence built from the structural facts, so it is exactly reproducible from the same DB state. + +**Business rules / edge cases:** +- `advisory_notice` is present, verbatim, on **every** response — it is not optional and not omittable by any input combination. +- This call performs **no writes**. It creates no Event, updates no cached drift state, and has zero side effects on the database. *Rationale: this is the concrete mechanism, not just a policy statement, by which "advisory only" is enforced — there is nothing in this call path that could be mistaken for a dispatch action.* +- If no unblocked items exist anywhere in scope, `ranked_items` is `[]` and `blocking_summary` lists the top blocked items and what's blocking them, so the caller has something actionable even when nothing is ready. +- A project or track with zero items returns `ranked_items: []` and no `blocking_summary` (nothing is blocked because nothing exists). + +**Acceptance criteria:** +- **Given** track T is `on_track` with item A (`not_started`, no deps, position 1) and item B (`not_started`, depends on A, position 2), **when** `kt_get_next_steps` is called, **then** `ranked_items[0].item_id == A`, `A.unblocked == true`, and B is either absent or ranked after A with `unblocked: false`. +- **Given** every item in the project is blocked on an undone dependency, **when** `kt_get_next_steps` is called, **then** `ranked_items` is `[]` and `blocking_summary` is non-empty. +- **Given** any valid input, **when** `kt_get_next_steps` is called, **then** the response's top-level object contains the field `advisory_notice` with the exact text specified above, and no Event row is created as a result of the call (verified by comparing the project's Event count before and after the call). +- **Given** track T2 is `blocked` and contains an otherwise-unblocked item C, **when** `kt_get_next_steps` is called with default `include_blocked_tracks: false`, **then** C does not appear in `ranked_items`; **when** called again with `include_blocked_tracks: true`, **then** C appears, ranked after any items from `on_track` tracks. + +### 4.6 `kt_create_track` + +**Description:** Create a new Track within a project. + +**Inputs:** `project_id` (required), `title` (required, 1–200 chars), `description` (optional), `depends_on` (optional array of `track_id`, must already exist in the same project), `initial_status` (optional enum, default `"on_track"`). + +**Output:** `{ track_id, title, description, status, depends_on, created_at, warnings: [] }` + +**Business rules / edge cases:** +- Track-level `depends_on` must not introduce a cycle in the project's track-dependency graph. Cycle detection is a DFS over all tracks in the project (existing + the one being created); on detection, the call fails with `CYCLE_DETECTED` and `details.cycle` lists the track IDs in cycle order. +- Any `depends_on` entry referencing a track_id that does not exist in this project → `VALIDATION`. +- Duplicate `title` across tracks in the same project is **allowed** (titles are not unique) but the response includes a `warnings` entry naming the other track_id(s) sharing that title, since two same-named tracks are legal but likely to confuse a human later. + +**Acceptance criteria:** +- **Given** track A already exists, **when** `kt_create_track` is called for track B with `depends_on: [A]`, **then** B is created successfully with `depends_on: [A]`. +- **Given** track A depends on track B, **when** `kt_create_track` is called to create a new "track" that is actually just B being re-declared to depend on A (i.e., would close A→B→A), **then** the call fails with `CYCLE_DETECTED`. +- **Given** a track named "Auth Rework" already exists, **when** another track named "Auth Rework" is created, **then** creation succeeds and the response's `warnings` names the existing track. + +### 4.7 `kt_create_item` + +**Description:** Create a new Item inside a Track. + +**Inputs:** `project_id`, `track_id` (required), `title` (required), `description` (optional), `sequence_position` (optional integer; auto-assigned as `max(existing positions in this track) + 1` if omitted), `depends_on` (optional array of `item_id`, must reference other items already in this same track), `file_patterns` (optional array of glob strings), `initial_status` (optional enum, default `"not_started"`). + +**Output:** `{ item_id, track_id, title, sequence_position, depends_on, file_patterns, status, created_at }` + +**Business rules / edge cases:** +- `sequence_position` is **not** required to be unique per track at the database level (see `docs/DATABASE_SCHEMA.md`'s `items` table). If the caller supplies a position already taken within that track, KnoTrack shifts every existing item at or after that position up by one (an atomic `UPDATE ... WHERE track_id = $1 AND sequence_position >= $2` inside the same transaction as the insert) so the new item lands at the requested position without ever producing a duplicate. *Rationale: rejecting the call would push the renumbering decision onto every caller; shifting keeps `sequence_position` values always contiguous and unique in practice without requiring a database-level uniqueness constraint that would make concurrent reordering race-prone.* +- `depends_on` cycle detection runs over **this track's item-dependency graph only** — items may only depend on other items in the same track, so cross-track cycles cannot occur; on cycle, `CYCLE_DETECTED` with the cycle path. +- Each entry in `file_patterns` is validated as syntactically valid glob syntax; an invalid entry → `VALIDATION` naming which pattern failed. +- `depends_on` referencing an item id that does not exist at all → `NOT_FOUND`. `depends_on` referencing an item id that exists but belongs to a different track → `VALIDATION`. + +**Acceptance criteria:** +- **Given** track T has items at positions 1 and 2, **when** `kt_create_item` is called with no `sequence_position`, **then** the new item is assigned position 3. +- **Given** track T has items at positions 1, 2, and 3, **when** `kt_create_item` is called with `sequence_position: 2`, **then** the call succeeds, the new item takes position 2, and the existing items previously at positions 2 and 3 now sit at 3 and 4 respectively. +- **Given** `file_patterns: ["src/**/*.ts", "[invalid"]`, **when** `kt_create_item` is called, **then** the call fails with `VALIDATION` naming `"[invalid"` as the offending pattern. +- **Given** item A depends on item B and B depends on item C, **when** `kt_create_item` is called to create/update C such that it would depend on A, **then** the call fails with `CYCLE_DETECTED`. + +### 4.8 `kt_record_session_summary` + +**Description:** The call an agent makes at the end of a working session. Appends an immutable Event and, inline, runs the same structural drift computation as `kt_check_drift`, scoped to this project (and `track_id` if given). + +**Inputs:** `project_id` (required), `track_id` (optional), `item_ids` (optional array of `item_id`), `files_touched` (required array of strings, may be `[]`), `summary` (required string, min 10 chars — a single word or empty string is rejected), `self_reported_drift` (optional boolean), `self_reported_drift_note` (optional string). + +Note: `client_id` is never a body parameter — it is resolved server-side from the authenticated bearer token (§5.3) and stamped onto the Event automatically, so a client cannot claim to be a different device than the one it authenticated as. + +**Output:** +``` +{ + event_id, created_at, + drift_result: { ...identical shape to kt_check_drift's output, scoped to this call... }, + warnings: [] // e.g. files_outside_project_root +} +``` + +**Business rules / edge cases:** +- `files_touched` is a required field (the array itself, not its contents) — an agent must explicitly pass `[]` for a planning-only session rather than omitting the field, so "no files changed" is always a deliberate statement, not an accidental gap. +- `summary` shorter than 10 characters is rejected with `VALIDATION` — this is a deliberate floor against meaningless session notes like "did stuff." +- This call **always** performs the full structural drift computation (§4.11's two categories) and (a) stores the result attached to this Event, and (b) updates the project's cached `drift_flags` / `drift_last_checked_at` that `kt_get_project_status` reads. +- **`self_reported_drift` is recorded for audit color only and never substitutes for, suppresses, or overrides the structural result.** If `self_reported_drift: false` but the structural check independently finds sequence or untracked-work drift, the response's `drift_result` still reports that drift. Conversely, if `self_reported_drift: true` but the structural check finds nothing, `drift_result`'s structural finding lists remain empty and the self-report appears only in `drift_result.self_reported_notes`. +- `item_ids` referencing items outside this project → `VALIDATION`. +- Paths in `files_touched` that fall outside the project's `root_path` (for local-folder projects only; not checked for repo_url-only projects, since there is no local root to compare against) are **not** rejected — legitimate monorepo/shared-file work happens — but are surfaced in `warnings.files_outside_project_root`. + +**Acceptance criteria:** +- **Given** a valid session with `files_touched: ["src/auth.ts"]` and `summary: "Implemented password reset flow"`, **when** `kt_record_session_summary` is called, **then** an Event is created, `drift_result` is present, and `kt_get_project_status`'s next call reflects the updated `drift_last_checked_at`. +- **Given** `summary: "ok"`, **when** `kt_record_session_summary` is called, **then** the call fails with `VALIDATION` (below the 10-character floor). +- **Given** `self_reported_drift: false` and an item in `item_ids` that is `in_progress` with an undone dependency and no covering Decision, **when** `kt_record_session_summary` is called, **then** `drift_result.sequence_drift` still contains that item's finding — the self-report does not suppress it. +- **Given** `files_touched: []` (explicit empty array) and a valid `summary`, **when** `kt_record_session_summary` is called, **then** the call succeeds (an explicit no-file-changes session is valid). +- **Given** `files_touched` omitted entirely, **when** `kt_record_session_summary` is called, **then** the call fails with `VALIDATION` (the field itself, not just its contents, is required). + +### 4.9 `kt_record_decision` + +**Description:** Record an explicit, human/agent-authored pivot or decision. Never inferred — a Decision only exists because this tool was called with real rationale text. + +**Inputs:** `project_id` (required), `title` (required), `rationale` (required, non-empty), `what_changed` (required, non-empty — concrete description, e.g. "Track B reprioritized ahead of Track A because the client moved up the Track B deadline"), `track_id` (optional), `item_ids` (optional array). + +**Output:** `{ decision_id, title, rationale, what_changed, track_id, item_ids, created_at }` + +**Business rules / edge cases:** +- `rationale` and `what_changed` being empty or whitespace-only strings → `VALIDATION`. This is the entire point of the entity: a Decision must carry real explanatory content, never a bare boolean. +- Decisions are immutable once created — there is no update or delete tool for Decisions in v1. A correction is made by recording a **new** Decision whose `rationale` references the earlier one by ID or description. *Rationale: this keeps the v1 API surface minimal; a formal `supersedes_decision_id` link is a natural, low-risk v2 addition once real usage shows people want it, but is not required for the core guarantee (an auditable, append-only decision trail) to hold in v1.* +- A Decision that names a `track_id` or includes an `item_id` in `item_ids` **suppresses future sequence-drift flags** for that track/item in any `kt_check_drift` or `kt_record_session_summary` call whose drift computation runs **after** this Decision's `created_at`. It is **not retroactive** — drift findings already recorded on earlier Events remain in the historical record unchanged; only future computations are affected. + +**Acceptance criteria:** +- **Given** `rationale: ""`, **when** `kt_record_decision` is called, **then** the call fails with `VALIDATION`. +- **Given** item X currently shows `SEQUENCE_DRIFT` under `kt_check_drift` because its dependency isn't done, **when** a Decision is recorded with `item_ids: [X]` and non-empty rationale/what_changed, **then** a subsequent `kt_check_drift` call no longer includes X in `sequence_drift`. +- **Given** the same scenario, **when** the *original* Event/finding that flagged X (recorded before the Decision existed) is inspected via `kt_get_project_status`'s historical `recent_events`, **then** that earlier flag is unchanged — the Decision does not rewrite history. + +### 4.10 `kt_update_item_status` + +**Description:** Change an Item's status. Never blocks on drift — advisory warnings only. + +**Inputs:** `project_id`, `item_id` (required), `new_status` (required enum: `not_started | in_progress | blocked | done`), `note` (optional). + +**Output:** `{ item_id, old_status, new_status, sequence_warning: boolean, details: { unmet_dependencies: [item_id...] } | null }` + +**Business rules / edge cases:** +- This call is **idempotent**: setting an item to its current status is allowed and returns success (with `sequence_warning` recomputed, not cached). +- If `new_status` is `in_progress` or `done` and at least one `depends_on` item is not `done`, and no Decision covers this item (per §4.9's suppression rule), the call **still succeeds** — `sequence_warning: true` is returned, but the status change is never rejected. *Rationale: KnoTrack is advisory-only end to end; a tool that refuses a status update because of a sequencing opinion would be making an execution decision, which is explicitly out of scope (§2.2).* +- This call does **not** run the full structural drift check or write a cached `drift_flags` update — that only happens via `kt_check_drift` or `kt_record_session_summary`. The `sequence_warning` here is a lightweight, synchronous, single-item check for immediate feedback, not the authoritative drift record. +- `item_id` not belonging to `project_id` → `NOT_FOUND`. + +**Acceptance criteria:** +- **Given** item A has an undone dependency and no covering Decision, **when** `kt_update_item_status` is called with `new_status: "done"`, **then** the call succeeds, `new_status == "done"`, and `sequence_warning == true` with the unmet dependency listed. +- **Given** the same scenario, **then** the status is actually persisted as `"done"` — the warning never prevents the write. +- **Given** item A is already `"done"`, **when** `kt_update_item_status` is called again with `new_status: "done"`, **then** the call succeeds (idempotent), returning the same status with `sequence_warning` recomputed against current data. + +### 4.11 `kt_check_drift` + +**Description:** Standalone, on-demand structural drift check. This is the authoritative computation that both this tool and `kt_record_session_summary` share. + +**Inputs:** `project_id` (required), `track_id` (optional, scopes the check), `since` (optional ISO8601 timestamp; default = the `checked_at` of the last drift computation for this exact scope, or the project's creation time if none exists yet). + +**Output:** +``` +{ + checked_at: "", + scope: { project_id, track_id }, + sequence_drift: [ { item_id, track_id, unmet_dependencies: [item_id...] } ], + untracked_work_drift: [ { file_path, event_id, occurred_at } ] | null, + untracked_work_coverage: "evaluated" | "not_evaluated_no_file_patterns", + self_reported_notes: [ { event_id, self_reported_drift_note, occurred_at } ] +} +``` + +**Drift categories (exact, reproducible definitions):** + +1. **`SEQUENCE_DRIFT`** — an Item whose current `status` is `in_progress` or `done`, where at least one ID in its `depends_on` refers to an item whose `status` is not `done`, **and** no Decision exists (with `created_at` at or before the check's `checked_at`) whose `track_id` or `item_ids` covers this item. Each finding lists the specific unmet dependency IDs. +2. **`UNTRACKED_WORK_DRIFT`** — a file path appearing in `files_touched` of any Event within the scoped time window (`since` → now) that does not match any Item's `file_patterns` glob anywhere in the project. This category is only computed (`untracked_work_coverage: "evaluated"`) if **at least one** Item in the project has a non-empty `file_patterns` array; otherwise `untracked_work_drift` is `null` and `untracked_work_coverage: "not_evaluated_no_file_patterns"`. *Rationale: most projects will not bother declaring `file_patterns` on every item; evaluating this category against a project with zero declared patterns would flag every single file ever touched as "untracked," which is noise, not signal — so the check honestly reports "not evaluated" instead of returning a false positive avalanche.* + +- `self_reported_notes` collects any Event in the window with `self_reported_drift: true`, listed purely for human/agent color — **never** merged into or treated as equivalent to `sequence_drift` / `untracked_work_drift` findings. +- **Side effect:** calling this tool updates the project's cached `drift_flags` / `drift_last_checked_at` (the same cache `kt_get_project_status` reads), scoped to whatever `track_id` scope was passed (a project-wide call updates the project-wide cache; a track-scoped call updates only that track's portion). *Rationale: an explicit, authoritative drift check that didn't update the cached status view would be silently ignored by `kt_get_project_status`, which defeats the purpose of running it on demand.* + +**Acceptance criteria:** +- **Given** item A (`in_progress`) depends on item B (`not_started`) and no Decision covers A, **when** `kt_check_drift` is called, **then** `sequence_drift` contains one entry for A listing B as an unmet dependency. +- **Given** the same scenario but a Decision covering A was recorded before this check's `checked_at`, **when** `kt_check_drift` is called, **then** `sequence_drift` does not include A. +- **Given** no Item in the project has a non-empty `file_patterns`, **when** `kt_check_drift` is called, **then** `untracked_work_drift` is `null` and `untracked_work_coverage == "not_evaluated_no_file_patterns"`. +- **Given** Item C declares `file_patterns: ["src/auth/**"]` and an Event in the scoped window touched `src/payments/checkout.ts` (matching no item's patterns), **when** `kt_check_drift` is called, **then** `untracked_work_drift` includes that file/event pair. +- **Given** an Event in the window has `self_reported_drift: true` but the file it touched matches a declared pattern and no sequence issue exists, **when** `kt_check_drift` is called, **then** that Event's note appears in `self_reported_notes` and nowhere in `sequence_drift` or `untracked_work_drift`. +- **Given** a prior drift check ran and updated the cache, **when** `kt_get_project_status` is called immediately after a new `kt_check_drift` call, **then** its `drift_flags`/`drift_last_checked_at` reflect the new call's results, not the older cached ones. + +### 4.12 `kt_render_roadmap` + +**Description:** Pure-function generation of `ROADMAP.md` from current database state. Never a write target for humans or agents — always fully regenerated, never merged with prior content. + +**Inputs:** `project_id` (required), `output_path` (optional). Default: if the project has a `root_path`, defaults to `/ROADMAP.md`. If the project has no `root_path` (repo_url-only), `output_path` is meaningless for a local write, so the tool instead returns the markdown as a string (see Output). + +**Output:** +- If a filesystem write occurred: `{ written: true, path, bytes_written, overwrote_untracked_file: boolean }` +- If no local filesystem target exists (`root_path` absent and no `output_path` given): `{ written: false, markdown: "" }` + +**Rendering rules:** +- Content: Tracks grouped by status (`on_track`, `pivot_pending`, `blocked`, `done` — in that order), each with its Items in `sequence_position` order showing status and any declared dependencies, followed by a reverse-chronological Decisions log section, followed by a footer line: ``. +- The renderer **never reads existing file content to merge** — it always overwrites completely. This is required, not incidental: `ROADMAP.md` is a projection, not a source of truth, and merging would risk silently preserving stale hand-edits as if they were still authoritative. +- If `output_path` already exists but does **not** contain the KnoTrack footer marker (i.e., it looks like a file KnoTrack did not generate), the render still proceeds and overwrites it, but the response sets `overwrote_untracked_file: true` so the caller/human is told, after the fact, that a non-KnoTrack file was replaced. *Rationale: KnoTrack has no way to ask for confirmation mid-call in a stateless MCP model, and refusing to render at all would make the tool useless the first time it's pointed at a path with an old hand-written roadmap already sitting there — so it proceeds, but never silently.* + +**Acceptance criteria:** +- **Given** a project with `root_path` set and no `output_path` given, **when** `kt_render_roadmap` is called, **then** `/ROADMAP.md` is written and the response has `written: true`. +- **Given** a project with only `repo_url` set (no `root_path`) and no `output_path` given, **when** `kt_render_roadmap` is called, **then** no file is written; the response has `written: false` and `markdown` contains the full rendered content. +- **Given** `/ROADMAP.md` already exists with hand-written content and no KnoTrack footer, **when** `kt_render_roadmap` is called, **then** the file is fully overwritten and the response has `overwrote_untracked_file: true`. +- **Given** `/ROADMAP.md` was itself generated by a previous `kt_render_roadmap` call (footer present) and contains stale content, **when** `kt_render_roadmap` is called again after DB state changed, **then** the file is fully overwritten to match current DB state (no partial merge of old and new content). + +### 4.13 `kt_sync_to_github` + +**Description:** Optional, off-by-default adapter for structured import/export against GitHub Issues. Only active if `adapters_enabled` includes `"github"` for the project and the server has a configured `GITHUB_TOKEN`. + +**Inputs:** `project_id` (required), `direction` (required enum: `pull_issues_as_items | push_track_as_milestone | push_item_as_issue`), `track_id` (required for `push_track_as_milestone`; optional filter for `pull_issues_as_items`), `item_id` (required for `push_item_as_issue`), `github_repo` (optional override; defaults to the project's `repo_url`). + +**Output (by direction):** +- `pull_issues_as_items`: `{ items_created: [item_id...], items_skipped_duplicate: [github_issue_url...], errors: [{ github_issue_url, error }] }` +- `push_track_as_milestone`: `{ github_milestone_url }` +- `push_item_as_issue`: `{ github_issue_url }` + +**Business rules / edge cases:** +- If the project's `adapters_enabled` does not include `"github"`, **or** the server has no `GITHUB_TOKEN` configured, the call fails immediately with `ADAPTER_NOT_CONFIGURED` and `details` naming exactly which precondition failed and how to fix it. This never silently no-ops. +- Each call is **one-directional only** — there is no automatic bidirectional merge or conflict resolution in v1. *Rationale: conflict resolution between two independently-editable sources of truth (GitHub Issues and KnoTrack Items) is a nontrivial UX and correctness problem; resolving it is deferred to v2, and forcing every sync to be an explicit, single-direction call avoids silent data loss in the meantime.* +- Deduplication on repeated `pull_issues_as_items` calls uses a dedicated `external_ref` field on Item (format: `"github:"`), so pulling the same GitHub issue twice does not create a duplicate Item — it is instead reported in `items_skipped_duplicate`. +- A bulk `pull_issues_as_items` call reports **partial results**, not all-or-nothing: if 8 of 10 issues import successfully and 2 fail (e.g., malformed issue body), the call still returns `items_created` for the 8 plus a per-issue `errors` array for the 2, rather than rejecting the whole batch. +- GitHub API unreachable or rate-limited → `UPSTREAM_ERROR`, including a `retry_after` field when GitHub's response provides one. + +**Acceptance criteria:** +- **Given** a project without `"github"` in `adapters_enabled`, **when** `kt_sync_to_github` is called, **then** the call fails with `ADAPTER_NOT_CONFIGURED` naming that the adapter isn't enabled for this project. +- **Given** the adapter is enabled and configured, and GitHub issue #42 was already pulled in as an Item, **when** `pull_issues_as_items` is called again over the same range, **then** issue #42 appears in `items_skipped_duplicate`, not `items_created`. +- **Given** a batch pull where 2 of 10 issues have malformed bodies, **when** `pull_issues_as_items` runs, **then** the response contains 8 entries in `items_created` and 2 in `errors`, and the call as a whole reports success (not a total failure). +- **Given** GitHub returns a 403 rate-limit response, **when** any direction is attempted, **then** the call fails with `UPSTREAM_ERROR` and `details.retry_after` set from GitHub's response header. + +### 4.14 `kt_sync_to_linear` + +**Description:** Optional, off-by-default adapter for structured import/export against Linear. Only active if `adapters_enabled` includes `"linear"` for the project and the server has a configured `LINEAR_API_KEY`. Linear's "Project" maps to KnoTrack's Track; Linear's "Issue" maps to KnoTrack's Item. + +**Inputs:** `project_id` (required), `direction` (required enum: `pull_issues_as_items | push_track_as_project | push_item_as_issue`), `track_id` (required for `push_track_as_project`; optional filter for `pull_issues_as_items`), `item_id` (required for `push_item_as_issue`), `linear_team_id` (optional override). + +**Output (by direction):** +- `pull_issues_as_items`: `{ items_created: [item_id...], items_skipped_duplicate: [linear_issue_id...], errors: [{ linear_issue_id, error }] }` +- `push_track_as_project`: `{ linear_project_url }` +- `push_item_as_issue`: `{ linear_issue_url }` + +**Business rules / edge cases (mirrors §4.13 exactly, with Linear-specific specifics):** +- Same `ADAPTER_NOT_CONFIGURED` precondition behavior, naming `"linear"` / `LINEAR_API_KEY` specifically. +- Same one-directional-per-call rule; same partial-result behavior on bulk pulls; same `UPSTREAM_ERROR` behavior for unreachable/rate-limited Linear API calls. +- Deduplication reuses the same Item `external_ref` field as the GitHub adapter, with a `"linear:"` prefix — the two adapters share one field on Item without collision because each writes its own distinguishable prefix, and an Item can in principle carry a synced reference from at most one adapter direction at a time per pull (re-running the other adapter's pull against an item that already has a `github:` ref does not overwrite it with a `linear:` ref; it is treated as a new, separate Item, since a single Item is not modeled as multi-sourced in v1). + +**Acceptance criteria:** +- **Given** a project without `"linear"` in `adapters_enabled`, **when** `kt_sync_to_linear` is called, **then** the call fails with `ADAPTER_NOT_CONFIGURED`. +- **Given** Linear issue `ISS-101` was already pulled in, **when** `pull_issues_as_items` runs again over the same team, **then** `ISS-101` appears in `items_skipped_duplicate`. +- **Given** `push_track_as_project` is called for track T, **when** the call succeeds, **then** the response contains a `linear_project_url` and no Item/Track data in KnoTrack's own database is mutated as a side effect of this push (push directions are exports; they do not loop back and rewrite the local record in v1). + +--- + +## 5. Non-Functional Requirements + +### 5.1 Deployment and data model + +- Self-hosted, single-tenant per instance: one Postgres database, one server process (or process group) per installer, per instance. No shared multi-tenant service exists or is offered by the maintainers. +- Because each instance is single-tenant, there is no per-request tenant-isolation logic to get wrong — every row in the database belongs to installer's own instance by construction. This is a deliberate simplification of the security model that a multi-tenant SaaS version would not get to make. +- Three documented deploy targets, each with a step-by-step guide shipped in the repo: Render + Supabase, Railway + Postgres, Fly.io. See §5.5 for the honest cost/limitation profile of each — none is hidden. + +### 5.2 Reliability + +- No formal uptime SLA is offered or meaningful for a self-hosted, installer-operated tool — uptime is the installer's own operational responsibility. KnoTrack's reliability requirements instead focus on **data integrity**, which the maintainers do control through the software's design: + - Event and Decision rows are strictly append-only at the application layer: the codebase must never issue an `UPDATE` or `DELETE` against the `events` or `decisions` tables under any code path. This is enforced by code review discipline plus a database-level revoke of `UPDATE`/`DELETE` privileges on those two tables for the application's DB role, so even a bug cannot silently rewrite history. + - Every multi-statement write across the 14 tools (e.g., `kt_record_session_summary`'s Event insert + drift-cache update; `kt_create_item`'s position check + insert) is wrapped in a single database transaction. A failure partway through never leaves partial rows. + - Performance target: `kt_check_drift` and `kt_get_next_steps` must complete in under 2 seconds for a project with up to 500 Items and 5,000 Events, measured on the smallest documented deploy tier (Render free). This bound is chosen because it is the ceiling of a realistic single-project, single-team scale (§4.0's pagination rationale) — not an enterprise-scale target. + +### 5.3 Security + +- **Bearer tokens, one per client device**, held client-side only in that tool's own MCP config (e.g., an environment variable or config field the harness reads to set the `Authorization: Bearer ` header). Tokens are opaque random 256-bit values. +- Token issuance is **not** an MCP tool — it is a server-side CLI/admin command run by the installer directly on the server (`knotrack create-token --project --label `), because a stateless MCP tool call would need a token to already exist in order to be authenticated in the first place. Issuance prints the raw token exactly once; it is stored server-side only as a salted hash (bcrypt or argon2id) and is never retrievable or re-displayed after creation. Revocation is `knotrack revoke-token `, also a server-side CLI command. +- Every one of the 14 MCP tool calls requires a valid, unrevoked bearer token. The token resolves to a `client_id` server-side; this resolved `client_id`, not any client-supplied field, is what gets stamped onto Events (§4.8), preventing a client from claiming to be a different device than the one it authenticated as. +- Adapter credentials (a GitHub personal access token, a Linear API key) are supplied inline as **input** to `kt_register_project`'s `adapters` field (§4.1) — this is the only way to set or rotate them, since there is no separate "update credentials" tool. They are encrypted (AES-256-GCM) before being persisted to the `adapters` table and are **never** included in any MCP tool's output — `kt_register_project` and every other tool return only `project_id`/derived fields, never the credential itself, so a credential cannot be echoed back into an agent's context window once stored. +- No cross-project data leakage: every scoped lookup (track/item/event/decision under a `project_id`) is validated as belonging to that project before being returned or mutated (§4.0), even though a single instance is single-tenant — this protects an installer who registers more than one unrelated project on the same instance. + +### 5.4 MCP client compatibility (stated exactly, not oversold) + +KnoTrack targets the **MCP 2026-07-28 spec**, which is stateless: no tool relies on server-side session memory, and every call is self-contained with explicit IDs (§4.0). Compatibility with specific clients was established as follows, and this is stated plainly rather than implied to be more thorough than it is: + +- **Claude Code / Cowork, Windsurf, Codex CLI, LM Studio:** compatibility verified via review of each project's own documentation and changelogs against the 2026-07-28 spec's requirements (tool call shape, error content format, stateless session handling). This is **not** equivalent to live end-to-end testing against a running instance of each client. +- **Goose and Hermes** (the two open-source clients in this list): compatibility was additionally checked by inspecting each project's pinned MCP SDK dependency version in its own repository, to confirm it implements the 2026-07-28 spec's tool-call and error semantics rather than an older, stateful predecessor. +- **In no case** was live, interactive testing performed against every listed client. Behavior may vary by client in ways documentation review would not surface (e.g., how a client renders a structured error object, or how it handles a very large tool-result payload). This is an accepted v1 limitation, not a hidden one — it is stated here so an installer knows exactly what "supported" means for their specific client, and so a client-specific bug report is understood as plausible, not surprising. + +### 5.5 Hosting: the honest cost/limitation profile of each documented path + +None of the three documented deploy targets is "free forever with zero catches." Each is stated here exactly as it is, so an installer is never surprised after the fact: + +| Target | Cost at start | The actual catch | +|---|---|---| +| **Render + Supabase** | Free, no card required | Render's free web service tier has **no persistent disk** (the app server itself must be stateless; all persistence must live in Supabase's Postgres, never on local disk). Supabase's free-tier project **pauses after 7 days of inactivity** and must be manually resumed from the dashboard before KnoTrack will respond again. | +| **Railway + Postgres** | Free trial | The free trial converts to a **paid plan after roughly 30 days, or sooner if usage exceeds about $5** of trial credit — whichever comes first. This is a real, near-term cost, not a permanently free tier. | +| **Fly.io** | Paid from day one | Requires a **credit card at signup**, even though usage may fall within a low-cost or nominally free usage band. In exchange, a Fly.io deployment **never sleeps/pauses** the way the other two free paths do, which matters for a tool other agents call into unpredictably throughout the day. | + +An installer who wants zero card entry and can tolerate an occasional manual "wake the database" click should choose Render+Supabase. An installer who wants a deployment that is always instantly responsive, and is willing to pay from the start, should choose Fly.io. Railway is a reasonable middle ground for roughly a month of genuinely free evaluation before a real billing decision is required. + +### 5.6 No telemetry + +KnoTrack does not collect or transmit usage analytics, crash reports, or any other telemetry from a self-hosted instance back to the maintainers, under any configuration. This is a design commitment, not a missing feature: it follows directly from the self-hosted, installer-owned distribution model (§7, §6.4) and from the license's attribution-only expectation (Apache 2.0 + NOTICE) rather than any data-sharing expectation. + +--- + +## 6. Success Metrics + +Because KnoTrack collects no central telemetry (§5.6), every metric below is something an installer can observe **on their own instance**, not something the maintainers aggregate across installs. This section describes what "working" looks like from inside one deployment, plus one manual, maintainer-run QA metric for the deploy paths themselves. + +1. **Session-summary discipline.** Fraction of working sessions that end with a real `kt_record_session_summary` call carrying a substantive (>10 char, non-generic) `summary`, versus sessions where no call was made at all. A rising fraction over time indicates the tool has become part of the actual workflow rather than a novelty. (Observable by the installer directly from their own Event table.) +2. **Drift trend, not drift count.** The ratio of sequence-drift findings that get an explicit covering Decision recorded soon after (a real "yes, we meant to do that") versus findings that recur unaddressed across multiple `kt_check_drift` calls. A tool that's working sees this ratio trend toward "explained," not toward "silently ignored" or "count trending to zero because no one runs the check anymore." +3. **`kt_get_next_steps` actually consulted before work starts.** Correlating ordinary HTTP/application access logs for `kt_get_next_steps` calls against the timestamps of subsequent Item status changes and Events — if items are consistently touched shortly after a next-steps call recommended them, the advisory loop is being used as intended. Note this is derived from access logs, not from the Event table, because `kt_get_next_steps` is deliberately side-effect-free (§4.5) and creates no Event of its own. +4. **`ROADMAP.md` freshness.** Time elapsed between a DB state change (new track/item, status change) and the next `kt_render_roadmap` call, measurable via git commit timestamps (for repo-backed projects) or file mtime (for local-only projects). A small, stable gap means the rendered roadmap is trustworthy as a snapshot; a growing gap means it's being ignored and the team is back to trusting stale docs. +5. **Adapter usage where enabled.** For projects with `"github"` or `"linear"` in `adapters_enabled`, the frequency of successful sync calls relative to manual, un-synced Item creation — if a project enables an adapter but never uses it, that's a signal the adapter isn't earning its complexity for that installer. +6. **Time-to-first-registered-project per deploy path (maintainer-run, manual QA).** Not automated telemetry — a manual checklist the maintainers run themselves against each of the three deploy targets in §5.5, timing from `git clone` to a successful first `kt_register_project` call. Target: under 30 minutes on each path, re-verified whenever a deploy guide or dependency changes. + +--- + +## 7. Out of Scope for v1 + +- **Work dispatch or orchestration of any kind.** No tool in this document assigns, triggers, queues, or executes work against any agent, CI system, or external runner. `kt_get_next_steps` is advisory only (§4.5); this boundary is treated as permanent product identity, not a temporary v1 gap — see §2.2. +- **Team/multi-user auth beyond per-device bearer tokens.** There is no role-based access control, no SSO/OAuth login flow, and no concept of a restricted or read-only token in v1 — every valid bearer token on an instance has full read/write access to every project that instance hosts. A small team (persona in §3.2) shares one instance with one token per developer device, all with equal privileges. Formal per-user roles and scoped/read-only tokens are a natural v2 addition once real multi-user usage shows which restrictions are actually wanted. +- **Real-time push UI.** `kt_get_project_status` and all other read tools are pull/poll-based only. There is no WebSocket/SSE channel, no live-updating dashboard, and no in-app or external notification (Slack, email, etc.) fired when drift is detected — a human or agent must proactively call a status/drift tool to find out. A future web UI, if built, would poll these same MCP tools rather than requiring a new push mechanism. +- **Automatic conflict resolution for bidirectional adapter sync.** `kt_sync_to_github` and `kt_sync_to_linear` are one-directional per call (§4.13, §4.14); there is no merge logic for the case where the same piece of work has diverged independently on both sides. +- **Automatic parsing of arbitrary local roadmap/spec file formats.** As stated in §1.3, KnoTrack does not itself ingest free-text planning documents from a local folder; the calling agent reads them and populates Tracks/Items via `kt_create_track`/`kt_create_item`. Only GitHub and Linear have structured import paths in v1. +- **Centralized, maintainer-run hosting or analytics.** There is no multi-tenant SaaS version of KnoTrack, and no telemetry collection of any kind (§5.6). "Open source, self-hosted" is the whole distribution model for v1, not a stepping stone the PRD assumes will change. +- **A standalone chat or web-app interface.** KnoTrack in v1 is purely an MCP tool surface. Whatever conversational interface a user experiences is provided entirely by the calling agent harness (Claude Code, Windsurf, etc.), not by KnoTrack itself. +- **Deleting or editing Tracks, Items, Events, or Decisions.** No tool in the 14 supports deletion or retroactive editing of any entity (status changes on Items are the one intentional exception, via `kt_update_item_status`, and are themselves append-only in effect since prior states remain visible in Event history). Corrections happen by recording new, forward-looking data (a new Decision, a new status), never by rewriting old rows. + +--- + +## 8. Glossary + +- **Project** — The top-level entity representing one software project KnoTrack has been pointed at. Identified by a `root_path` (local folder), a `repo_url` (GitHub), or both. All other entities belong to exactly one Project. +- **Track** — A grouping of related work within a Project (roughly: an epic or workstream), with a `status` of `on_track`, `pivot_pending`, `blocked`, or `done`, and optional declared dependencies on other Tracks. +- **Item** — A single, discrete piece of work inside a Track. Has a `sequence_position` (its declared order within the Track), a `status` (`not_started`, `in_progress`, `blocked`, `done`), optional dependencies on other Items (which may live in different Tracks), and optional `file_patterns` used by drift detection to associate real file changes with declared work. +- **Event** — An append-only log entry created by `kt_record_session_summary`, recording what a client (an agent session) did: which files were touched, a human-readable summary, and an optional self-reported drift opinion. Events are never edited or deleted once written; they are the raw material structural drift detection is computed from. +- **Decision** — An explicit, append-only record of an intentional pivot or plan change: a `title`, a `rationale` (why), and a `what_changed` description (what concretely changed). Decisions are never inferred from a status change or a boolean flag — they only exist because `kt_record_decision` was deliberately called with real content. A Decision covering a Track or Item suppresses future sequence-drift findings for it, but never rewrites past findings. +- **Drift** — A structurally-computed mismatch between the declared plan and what actually happened, evaluated by `kt_check_drift` (and inline by `kt_record_session_summary`) from the Event log, never from a self-reported opinion alone. Two kinds exist in v1: **sequence drift** (an Item was advanced to `in_progress`/`done` while a declared dependency was still undone, with no covering Decision) and **untracked-work drift** (a file was touched in a session that matches no Item's declared `file_patterns` anywhere in the project — only evaluated when at least one Item has declared patterns). +- **Adapter** — An optional, per-project, off-by-default integration to an external source-of-truth system (GitHub Issues or Linear) that provides structured, one-directional import/export of Tracks and Items, gated on both the project explicitly enabling it and the server holding the relevant credential. Adapter credentials are always server-side only, never passed through any MCP tool. +- **Advisory** — The general operating principle behind `kt_get_next_steps` and, in effect, every other KnoTrack tool: the system recommends, tracks, and reports, but never assigns, dispatches, or blocks a human/agent's actual actions. + +--- + +## 9. Appendix: Data Model Reference + +For implementer convenience, the full field list per entity (Postgres-flavored types; `depends_on`-style arrays are stored as JSONB arrays of ID strings or a join table, implementer's choice, as long as the cycle-detection behaviors in §4.6/§4.7 hold — see `docs/DATABASE_SCHEMA.md` for the canonical, authoritative column definitions): + +**Project** +`id (uuid pk)`, `name (text)`, `source_type (text — "github" | "linear" | "local")`, `source_ref (text, nullable)`, `created_at (timestamptz)`, `updated_at (timestamptz)`, `deleted_at (timestamptz, nullable — soft delete)`. Adapter credentials live in a separate `adapters` table (one row per project+type), never inline on `projects`. + +**Track** +`id (uuid pk)`, `project_id (uuid fk)`, `title (text)`, `description (text, nullable)`, `status (enum: on_track|pivot_pending|blocked|done)`, `depends_on (uuid[] of track ids)`, `created_at`, `updated_at` + +**Item** +`id (uuid pk)`, `track_id (uuid fk)`, `title (text)`, `description (text, nullable)`, `status (enum: not_started|in_progress|blocked|done)`, `sequence_position (integer, not unique per track_id — see §4.7's shift-on-insert behavior)`, `depends_on (uuid[] of item ids, must be in the same track)`, `file_patterns (text[], nullable)`, `external_ref (text, nullable — "github:" or "linear:")`, `created_at`, `updated_at` + +**Event** (append-only: no UPDATE/DELETE grants at the DB role level) +`id (uuid pk)`, `project_id (uuid fk)`, `track_id (uuid fk, nullable)`, `item_ids (uuid[], nullable)`, `client_id (text, resolved from bearer token, not client-supplied)`, `files_touched (text[])`, `summary (text)`, `self_reported_drift (boolean, nullable)`, `self_reported_drift_note (text, nullable)`, `structural_drift_result (jsonb — the drift_result computed inline at write time)`, `created_at` + +**Decision** (append-only: no UPDATE/DELETE grants at the DB role level) +`id (uuid pk)`, `project_id (uuid fk)`, `track_id (uuid fk, nullable)`, `item_ids (uuid[], nullable)`, `title (text)`, `rationale (text, non-empty)`, `what_changed (text, non-empty)`, `created_by (text, client_id from bearer token)`, `created_at` + +**Auth token store** (server-side only; never exposed via any MCP tool) +`id (text pk)`, `project_id (uuid fk, nullable — a token may be scoped instance-wide in v1 since there is no RBAC, see §7)`, `label (text — device name)`, `token_hash (text, bcrypt/argon2id)`, `created_at`, `revoked_at (timestamptz, nullable)` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..016c9e1 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,465 @@ +# KnoTrack Roadmap + +KnoTrack is a self-hosted MCP server that gives an AI coding agent (and its +human) a shared, durable view of project status, sequencing, and drift — it +is explicitly **not** an orchestrator and never dispatches work. This +document plans the build of KnoTrack itself, phased into eight Tracks. + +**This roadmap has a second job.** Once the KnoTrack server exists and runs, +KnoTrack will register itself as a KnoTrack project +(`kt_register_project`), and every Track and Item below will be created +in that instance via `kt_create_track` / `kt_create_item` — so KnoTrack +tracks the build of KnoTrack from that point forward (see "Dogfood cutover" +below, and the seeding procedure at the end of this document). Because this +file *is* the seed data, every Item is written as a single, checkable +deliverable with one acceptance criterion, and every dependency — within a +Track, across Tracks, or on a specific prior Item — is stated explicitly +rather than implied by ordering. + +## Legend + +- **Track ID**: `T1`–`T8`. **Item ID**: `T.`, e.g. `T3.4`. +- **Track status** uses the real `tracks.status` enum from the schema + (`docs/... DB schema`, `migrations/001_init.sql`): `on_track`, + `pivot_pending`, `blocked`, `done`. All Tracks start `blocked` except + the one Track nothing else is waiting on. +- **Item status** uses the real `items.status` enum: `pending`, + `in_progress`, `done`, `blocked`. Every Item below starts `pending` + unless noted; T1's items are already substantially satisfied in reality + (the specs exist) and will be backfilled to `done` at cutover (`T8.3`). +- **`depends_on`** on a Track lists prior Track(s) that must be `done` + first, mapping to the `track_dependencies` table. **`depends_on`** on an + Item lists prior Item(s) — possibly in another Track — that must be + `done` first, mapping to the `item_dependencies` table. + +## The 14 MCP tools + +Referenced throughout by name; this is the full and final canonical set for +v1, as fixed in the TRD/Architecture/Test-Cases docs. (An earlier draft of +this roadmap used a different, non-canonical tool list — corrected here; +if you're diffing against history, everything below is the fix.) + +| # | Tool | Purpose | +|---|------|---------| +| 1 | `kt_register_project` | Register (or upsert) a project KnoTrack will track | +| 2 | `kt_get_project_status` | Roll-up view: tracks, items, open drift flags, recent events | +| 3 | `kt_list_tracks` | List a project's tracks, optionally filtered by status | +| 4 | `kt_get_track` | Track detail: items + dependency graph | +| 5 | `kt_get_next_steps` | **Advisory only** ranked list of unblocked items — never writes anything | +| 6 | `kt_create_track` | Create a Track under a project | +| 7 | `kt_create_item` | Create an Item under a Track | +| 8 | `kt_record_session_summary` | Append a session's summary + files/items touched; runs the drift check inline | +| 9 | `kt_record_decision` | Log an explicit pivot/decision against a track; sets that track's stored status to `pivot_pending` | +| 10 | `kt_update_item_status` | Change an Item's status | +| 11 | `kt_check_drift` | Return open drift flags for a project | +| 12 | `kt_render_roadmap` | Generate a roadmap document from current DB state (pure read, never a write target) | +| 13 | `kt_sync_to_github` | Push an Item to a linked GitHub Issue | +| 14 | `kt_sync_to_linear` | Push an Item to a linked Linear Issue | + +Issuing bearer tokens (`api_tokens`) is an operator action done via an +admin CLI/script at deploy time, not one of the 14 MCP tools. There is no +`kt_update_track`, `kt_list_items`, `kt_get_session_history`, or +`kt_archive_project` tool: track status is a stored column that only ever +changes as a side effect of `kt_create_track` (initial value) and +`kt_record_decision` (→ `pivot_pending`) — never set directly; an item's +containing track already comes back from `kt_get_track`, so no separate +listing tool is needed; recent events are already part of +`kt_get_project_status`'s response; and project archival/deletion is +explicitly out of scope for v1 per `PRD.md` §7. + +--- + +## T1 — Spec sign-off + +**Status:** `on_track` (the only Track not `blocked` initially — nothing +else can start before this one). +**depends_on:** none. + +1. **T1.1 — PRD finalized and approved.** Acceptance: PRD is committed + under `docs/`, and the maintainer's sign-off is recorded (approving PR + review or a dated note in `docs/SIGNOFF.md`). +2. **T1.2 — TRD finalized and approved.** Acceptance: TRD is committed and + approved; every functional requirement in the PRD maps to at least one + TRD section. +3. **T1.3 — Architecture doc finalized.** Acceptance: architecture doc is + committed, including a component diagram and the deployment topology + for all three target platforms (Railway, Render+Supabase, Fly.io). +4. **T1.4 — DB schema finalized.** Acceptance: `migrations/001_init.sql` + (+ its `.down.sql`) is committed, covers every entity in the TRD + (`projects`, `adapters`, `tracks`, `track_dependencies`, `items`, + `item_dependencies`, `events`, `decisions`, `api_tokens`, + `drift_flags`), and `docs/DATABASE_SCHEMA.md` documents the design + choices referenced in the migration's header comment. +5. **T1.5 — Test case matrix authored for all 14 MCP tools.** Acceptance: + a committed test-case doc lists at least one happy-path and one + failure-path case per tool in the table above (26+ cases total), + cross-checked against the TRD's tool contracts. +6. **T1.6 — Cross-document consistency pass.** Acceptance: a single + reviewer pass confirms the PRD, TRD, architecture doc, DB schema, and + test-case matrix use consistent naming, entities, and tool signatures + with zero open discrepancies; sign-off recorded in + `docs/SIGNOFF.md`. depends_on: `T1.1`, `T1.2`, `T1.3`, `T1.4`, `T1.5`. + +--- + +## T2 — Core MCP server (local, Postgres) + +**Status:** `blocked`. +**depends_on:** `T1`. + +Ten of the 14 tools are fully implemented here. `kt_sync_to_github`, +`kt_sync_to_linear`, `kt_record_session_summary`, and `kt_check_drift` get +working stub implementations now (correct request/response shape, no +external API calls, no real drift heuristics) — full behavior for the +sync tools lands in T5, and for the two drift-related tools in T6. + +1. **T2.1 — Local Postgres migrations runnable.** Acceptance: `migrate up` + against a clean local Postgres 13+ instance creates every table in + `001_init.sql` with no errors; `migrate down` cleanly reverses it. + depends_on: `T1.4`, `T1.6`. +2. **T2.2 — `kt_register_project` implemented + unit-tested.** Acceptance: + inserts (or upserts on `(source_type, source_ref)`) a row into + `projects`; unit tests cover the T1.5 happy-path and failure-path cases + for this tool. depends_on: `T2.1`. +3. **T2.3 — `kt_create_track` implemented + unit-tested.** Acceptance: + inserts a row into `tracks` (status `on_track`, or `blocked` if any + listed `depends_on` track is not yet `done`) scoped to a project, + writes the `depends_on` list to `track_dependencies`, rejects a + dependency cycle with `409`; unit-tested per T1.5. depends_on: `T2.2`. +4. **T2.4 — `kt_create_item` implemented + unit-tested.** Acceptance: + inserts a row into `items` (default status `pending`) scoped to a + track, accepts a `depends_on` list written to `item_dependencies`, + rejects a dependency cycle with `409`; unit-tested per T1.5. + depends_on: `T2.3`. +5. **T2.5 — `kt_list_tracks` implemented + unit-tested.** Acceptance: + returns a project's tracks with their stored `status`, optionally + filtered by `status`; unit-tested per T1.5. depends_on: `T2.3`. +6. **T2.6 — `kt_get_track` implemented + unit-tested.** Acceptance: + returns one track plus its items (ordered by `sequence_position`) and + dependency graph; unit-tested per T1.5. depends_on: `T2.4`. +7. **T2.7 — `kt_get_project_status` implemented + unit-tested.** + Acceptance: returns tracks, items, and open `drift_flags` for a + project in one call; unit-tested per T1.5. depends_on: `T2.6`. +8. **T2.8 — `kt_update_item_status` implemented + unit-tested.** + Acceptance: updates an item's `status`; rejects a transition to `done` + with `409` while any `depends_on_item_id` is not `done`; unit-tested + per T1.5. depends_on: `T2.4`. +9. **T2.9 — `kt_record_session_summary` stub implemented + unit-tested.** + Acceptance: inserts a row into `events` (`summary_text`, + `files_touched`, `items_touched`) with no drift analysis performed + yet; unit-tested per T1.5. depends_on: `T2.4`. +10. **T2.10 — `kt_record_decision` implemented + unit-tested.** + Acceptance: inserts a row into `decisions` and, in the same + transaction, sets the referenced track's stored `status` to + `pivot_pending`; unit-tested per T1.5. depends_on: `T2.3`. +11. **T2.11 — `kt_check_drift` stub implemented + unit-tested.** + Acceptance: returns an empty result with a `"no heuristics + configured"` note rather than querying `drift_flags` for real + findings; unit-tested per T1.5. depends_on: `T2.4`. +12. **T2.12 — `kt_render_roadmap` implemented + unit-tested.** Acceptance: + generates a Markdown document from current tracks/items with zero + database writes (asserted by a negative test); unit-tested per T1.5. + depends_on: `T2.7`. +13. **T2.13 — `kt_sync_to_github` stub implemented + unit-tested.** + Acceptance: validates its inputs and an item's existence, returns a + `"github adapter not configured"` result, makes no HTTP calls; + unit-tested per T1.5. depends_on: `T2.4`. +14. **T2.14 — `kt_sync_to_linear` stub implemented + unit-tested.** + Acceptance: validates its inputs and an item's existence, returns a + `"linear adapter not configured"` result, makes no HTTP calls; + unit-tested per T1.5. depends_on: `T2.4`. +15. **T2.15 — Local server boots against local Postgres, full suite + green.** Acceptance: the server starts locally (stdio or local HTTP), + connects only to the local Postgres instance (zero outbound network + calls), and the full unit-test suite covering `T2.2`–`T2.14` passes. + depends_on: `T2.2`, `T2.3`, `T2.4`, `T2.5`, `T2.6`, `T2.7`, `T2.8`, + `T2.9`, `T2.10`, `T2.11`, `T2.12`, `T2.13`, `T2.14`. + +`kt_get_next_steps` (tool 5 of 14) is deliberately **not** in this list: +it has no dedicated storage or side effects to build — it's a read query +composed entirely from tracks/items already implemented by `T2.3`–`T2.8`, +so it is built and unit-tested as part of `T2.15`'s hardening pass rather +than getting its own numbered item. + +--- + +## T3 — Deploy + auth (Railway reference deployment) + +**Status:** `blocked`. +**depends_on:** `T2`. + +1. **T3.1 — Railway project + managed Postgres provisioned.** + Acceptance: a Railway project exists with a Postgres add-on attached + and its connection string stored as a Railway secret. + depends_on: `T2.15`. +2. **T3.2 — Migrations applied to the Railway Postgres instance.** + Acceptance: the same migration set from `T2.1` runs clean against the + Railway database with no manual intervention. depends_on: `T3.1`. +3. **T3.3 — KnoTrack server running on Railway.** Acceptance: the server + is deployed as a persistent Railway service, reachable over HTTPS at a + stable URL, and a health-check endpoint returns 200. + depends_on: `T3.2`. +4. **T3.4 — Bearer token auth enforced end-to-end.** Acceptance: an + unauthenticated MCP request to any tool is rejected with 401; a + request bearing a valid token (issued via the admin CLI against + `api_tokens`) succeeds; both cases are unit-tested. depends_on: `T2.15`. +5. **T3.5 — Bearer auth verified from one real MCP client.** Acceptance: + a real MCP client (e.g. Claude Desktop or Claude Code) configured with + an issued bearer token successfully calls `kt_register_project` and + `kt_create_track` against the Railway deployment. + depends_on: `T3.3`, `T3.4`. +6. **T3.6 — Railway reference-deployment runbook drafted.** Acceptance: + `docs/deploy/railway.md` documents provisioning, required env vars, + secret rotation, and rollback steps, sufficient for someone unfamiliar + with the project to redeploy from scratch. depends_on: `T3.5`. + +--- + +## T4 — Second-client verification + +**Status:** `blocked`. +**depends_on:** `T3`. + +1. **T4.1 — Second MCP client configured against the existing server.** + Acceptance: a second, different MCP client (e.g. Windsurf) is pointed + at the same Railway URL and bearer token used in `T3.5`, with zero + server-side code or config changes. depends_on: `T3.3`, `T3.4`. +2. **T4.2 — Full tool-call smoke test from the second client.** + Acceptance: from the second client, `kt_register_project`, + `kt_create_track`, `kt_create_item`, `kt_update_item_status`, + `kt_get_next_steps`, `kt_check_drift`, and `kt_record_session_summary` + all succeed against the same instance, with results matching what + `T3.5` observed from the first client. depends_on: `T4.1`, `T3.5`. +3. **T4.3 — Client-compatibility notes documented.** Acceptance: + `docs/client-compatibility.md` records any client-specific quirks + observed in `T4.2` and confirms none required a server change. + depends_on: `T4.2`. + +--- + +## T5 — GitHub + Linear adapters + +**Status:** `blocked`. +**depends_on:** `T4`. + +1. **T5.1 — Credential encryption at rest implemented.** Acceptance: + `adapters.encrypted_credential` is written using envelope encryption + (e.g. AES-256-GCM under a server-held master key), the plaintext token + is never persisted, and a unit test confirms the stored bytes are + ciphertext with a correct decrypt round-trip. depends_on: `T2.15`. +2. **T5.2 — `kt_sync_to_github` fully implemented.** Acceptance: given a + stored encrypted GitHub credential, calling `kt_sync_to_github` + creates/updates a linked GitHub Issue for a KnoTrack item and records + the issue URL on the item, verified against one real test repository. + depends_on: `T2.12`, `T5.1`. +3. **T5.3 — `kt_sync_to_linear` fully implemented.** Acceptance: given a + stored encrypted Linear credential, calling `kt_sync_to_linear` + creates/updates a linked Linear issue for a KnoTrack item and records + the issue URL on the item, verified against one real test Linear + workspace. depends_on: `T2.13`, `T5.1`. +4. **T5.4 — Credential revocation path implemented + unit-tested.** + Acceptance: deleting a stored GitHub/Linear credential causes the next + sync call to fail with a clear "credential not configured" error + rather than crashing or silently using a stale token. + depends_on: `T5.2`, `T5.3`. + +--- + +## T6 — Drift heuristics + +**Status:** `blocked`. +**depends_on:** `T5`. + +1. **T6.1 — Out-of-sequence detection implemented.** Acceptance: when an + item is marked `done` while an item it `depends_on` (via + `item_dependencies`) is not `done`, the engine writes a `drift_flags` + row with `kind = 'out_of_sequence'`; unit tests cover both a + true-positive and a true-negative case. depends_on: `T2.15`. +2. **T6.2 — Orphan file-change detection implemented.** Acceptance: when + a recorded session's `files_touched` includes a path not associated + with any open item in the project, the engine writes a `drift_flags` + row with `kind = 'orphan_file_change'`; unit tests cover both a + true-positive and a true-negative case. depends_on: `T2.9`, `T6.1`. +3. **T6.3 — Drift engine wired into `kt_record_session_summary`.** + Acceptance: calling `kt_record_session_summary` now runs both + heuristics from `T6.1`/`T6.2` against the recorded session and + persists any resulting `drift_flags` rows. + depends_on: `T6.1`, `T6.2`, `T2.9`. +4. **T6.4 — Drift engine wired into `kt_check_drift`.** Acceptance: + calling `kt_check_drift` returns real open `drift_flags` rows for a + project, replacing the `T2.11` stub's "no heuristics configured" + response. depends_on: `T6.1`, `T6.2`, `T2.11`. +5. **T6.5 — Drift heuristics end-to-end test suite green.** Acceptance: + a fixture-project test suite covering `T6.3` and `T6.4` passes, with + both tools returning real (non-stub) drift data. + depends_on: `T6.3`, `T6.4`. + +--- + +## T7 — Public release prep + +**Status:** `blocked`. +**depends_on:** `T4`, `T5`, `T6`. + +1. **T7.1 — Render+Supabase deploy track verified.** Acceptance: the same + codebase (same commit as the Railway reference) is deployed to Render + with Supabase Postgres, the health-check passes, and one real MCP + client successfully calls `kt_register_project` against it. + depends_on: `T6.5`. +2. **T7.2 — Fly.io deploy track verified.** Acceptance: the same codebase + is deployed to Fly.io, the health-check passes, and one real MCP + client successfully calls `kt_register_project` against it. + depends_on: `T6.5`. +3. **T7.3 — Deployment runbook finalized for all three platforms.** + Acceptance: `docs/deploy/` contains an independently followable + runbook per platform (Railway, Render+Supabase, Fly.io). + depends_on: `T7.1`, `T7.2`, `T3.6`. +4. **T7.4 — Adversarial-review pipeline passes on the release commit.** + Acceptance: the multi-model adversarial-review pipeline is run against + the candidate release commit and returns zero CONFIRMED blocking + findings. depends_on: `T7.1`, `T7.2`. +5. **T7.5 — License, NOTICE, and README finalized.** Acceptance: + `LICENSE`, `NOTICE` (third-party attributions), and `README.md` + (install, quickstart, and a reference entry for all 14 tools) are + committed and reviewed. depends_on: `T7.4`. +6. **T7.6 — Release commit tagged.** Acceptance: a git tag (e.g. + `v1.0.0`) is created on the commit that passed `T7.4`, with a matching + `CHANGELOG.md` entry. depends_on: `T7.5`. + +--- + +## T8 — Dogfood cutover + +**Status:** `blocked`. +**depends_on:** `T7`. + +1. **T8.1 — KnoTrack registers itself as a tracked project.** + Acceptance: `kt_register_project` is called against the released, + deployed KnoTrack instance with name `"KnoTrack"`, returning a + `project_id`. depends_on: `T7.6`. +2. **T8.2 — This roadmap loaded as Tracks and Items.** Acceptance: all + eight Tracks and every Item in this document exist in the running + instance via `kt_create_track`/`kt_create_item` calls, with + `depends_on` fields matching this document exactly. + depends_on: `T8.1`. +3. **T8.3 — Historical status backfilled.** Acceptance: every completed + Item under `T1`–`T7` is set to `done` via `kt_update_item_status` + (there is no track-status tool by design — see the tool table above — + so each track's stored status is instead brought to `done` the same + way it would happen in normal operation: by its items all reaching + `done`, seeded directly at the storage layer for this one backfill + pass since these events predate the running instance); any real pivot + that occurred during T1–T7 is additionally recorded via + `kt_record_decision` so the audit trail isn't silently backdated. + depends_on: `T8.2`. +4. **T8.4 — Session-recording cutover.** Acceptance: `CONTRIBUTING.md` + states that all further KnoTrack development sessions are recorded via + `kt_record_session_summary` instead of ad hoc notes, and the first + real (non-seed) session after cutover is recorded this way. + depends_on: `T8.3`. + +--- + +## How this becomes the dogfood seed + +Once `T7.6` is done and a bearer token exists for the release deployment, +loading this roadmap into that running instance is a straight, one-pass +walk of the document — Tracks in `T1`…`T8` order, then each Track's Items +in the order listed — because every `depends_on` above only ever points at +a Track or Item that appears earlier in that same walk. + +``` +project = kt_register_project(name="KnoTrack", source_type="github", + source_ref="/knotrack") + +track_id = {} # roadmap Track id -> real track id +item_id = {} # roadmap Item id -> real item id + +for track in [T1, T2, T3, T4, T5, T6, T7, T8]: # in document order + track_id[track.id] = kt_create_track( + project_id = project.id, + title = track.title, + status = track.status, # "on_track" for T1, else "blocked" + depends_on = [track_id[d] for d in track.depends_on], + ) + + for item in track.items: # in document order within the track + item_id[item.id] = kt_create_item( + project_id = project.id, + track_id = track_id[track.id], + title = item.title, # e.g. "kt_register_project implemented + unit-tested" + # acceptance criterion goes wherever the schema's free-text + # field for it lives (e.g. embedded in `title` or a + # `description`-style column, per the finalized TRD/DB schema) + depends_on = [item_id[d] for d in item.depends_on], # may reference items in earlier tracks + ) + +kt_record_session_summary( + project_id = project.id, + summary_text = "Seeded KnoTrack's own roadmap (docs/ROADMAP.md) as the " + "initial Tracks/Items; backfilled T1-T7 status to match " + "reality as of the cutover date.", + files_touched = ["docs/ROADMAP.md"], + items_touched = [item_id["T8.2"], item_id["T8.3"]], +) +``` + +After this runs, `T8.4` is satisfied going forward: every subsequent +KnoTrack development session is recorded with `kt_record_session_summary` +against this same `project_id`, and `kt_check_drift` / +`kt_get_project_status` on that project are the live status of KnoTrack's +own development from that point on. + +--- + +## Backlog: external research, borrowed vs. rejected + +Reviewed two comparable tools ([automazeio/ccpm](https://github.com/automazeio/ccpm) +and the [mcpmarket.com project-management skill](https://mcpmarket.com/tools/skills/project-management-3)) +partway through this build. Recorded here so the decisions aren't re-litigated later. + +**Borrowed (backlog, not v1-blocking):** +- **`T9.x` (new, unscheduled) — thin CLI wrapper.** CCPM runs deterministic + read operations (status, standup) as plain scripts with zero LLM token + cost. `kt_get_project_status` is already a deterministic query under the + hood; exposing the same service-layer call as a local CLI command (no + MCP round-trip, no agent required) is a cheap, additive win for humans + and CI. Not needed for v1; candidate for right after `T7`. +- **`T9.x` (new, unscheduled) — companion `SKILL.md`.** CCPM ships an + "Agent Skills"-format file alongside its GitHub-Issues backbone, which + reportedly gets picked up by Factory and Cursor in addition to Claude. + Pairing our existing `AGENTS.md` breadcrumb with a `SKILL.md` is cheap + and purely additive — does not replace the MCP-first strategy, just + gives a second, lower-effort discovery path for harnesses that support + the convention but haven't wired up our MCP server yet. +- **Backlog idea, not scheduled — per-Item long-form notes.** The + mcpmarket skill keeps a `spec.md`/`plan.md`/`findings.md` per tracked + issue. KnoTrack's `items` table has no equivalent free-text field today + (only `tracks.source_doc_ref` exists, at the track level). Worth + revisiting if real usage shows items need more context than a title — + deliberately not added now to avoid speculative schema growth. + +**Considered and rejected:** +- **CCPM's git-worktree parallel-execution model** (decomposing an issue + into work streams, running multiple agents across isolated worktrees). + This is dispatch — the exact orchestrator behavior KnoTrack was + explicitly scoped to never do, from the very first design conversation. + Adopting it would reverse that line on purpose. Rejected. +- **The mcpmarket skill's rigid six-phase workflow** (Start→Specify→Plan→ + Implement→PR→Sync) and its **"companion agents" for background + bookkeeping.** Both push KnoTrack toward prescribing *how* work gets + done, or running its own always-on agent loop — contradicting the + deterministic, self-hosted, no-daemon design already settled on + (see `ARCHITECTURE.md` §6, the anti-orchestrator argument). Rejected. + +**Deferred from the v1 adversarial review (see `suppressions.json` in the +review run for the full technical justification and expiry dates):** +- **`T9.x` (new, unscheduled) — DB-operation retry/backoff.** No service + function retries a transient DB failure (connection reset, serialization + error) today; a failure just fails fast and rolls back cleanly (the + transaction wrapper already guarantees no partial state — see + `ARCHITECTURE.md` §... failure-mode notes). Real gap, but a correct + generic retry layer needs to reason about which operations are safe to + retry blindly, which is a bigger, cross-cutting change than fits + reactively inside one review; tracked here instead of built ad hoc. + Suppression expires 2026-11-23 — revisit before then. diff --git a/docs/TEST_CASES.md b/docs/TEST_CASES.md new file mode 100644 index 0000000..b9a5991 --- /dev/null +++ b/docs/TEST_CASES.md @@ -0,0 +1,508 @@ +# KnoTrack MCP Server — Test Cases + +Scope: the 14 MCP tools exposed by KnoTrack (self-hosted MCP server for project +management support). This document is the test-case spec a builder should be +able to implement against directly, without inventing missing cases. + +## 0. Conventions & Assumptions + +These are fixed once here so individual rows don't repeat the reasoning. + +1. **Cross-tenant reference status code: 404, not 403.** When a caller's + bearer token is valid but the `project_id`/`track_id`/`item_id` in the + request belongs to a *different* token's project, every tool returns + **404 Not Found**, identical in shape to a genuinely nonexistent ID. This + is a single-tenant-per-deployment system where a deployment can still + hold multiple projects under different tokens; a 403 would confirm to an + attacker that the ID *exists* but isn't theirs (information disclosure + via status-code oracle). 404 gives the same signal for "doesn't exist" + and "not yours," which is the safer default. All test rows below use 404 + for this case; if an implementation deliberately chooses 403 instead, it + must do so consistently across all 14 tools and all ID types (project, + track, item) — a mix of 403 and 404 depending on which ID mismatches is + itself a bug worth its own test (see AUTH-08). +2. **Auth failure status code: 401** for missing, malformed, expired, or + revoked tokens — always, on every tool, no exceptions (per spec). 401 is + returned *before* any ID/ownership check runs, so an invalid token + against a nonexistent project still yields 401, not 404 (see AUTH-07). +3. **Validation failure status code: 400** for missing required fields, + wrong types, and out-of-enum values, unless a more specific code applies + (404 for a dangling reference to another entity, 409 for a dependency + cycle). +4. **Dependency-cycle status code: 409 Conflict**, and the create call must + be fully rejected — no partial track/item is persisted. +5. **IDs.** `project_id`, `track_id`, `item_id`, `event_id`, `decision_id` + are opaque server-generated identifiers. "Nonexistent ID" test rows use a + syntactically well-formed but never-issued ID (e.g. a fresh random + UUID/ULID matching the ID format) rather than a malformed string, to + isolate "not found" from "bad format" (malformed-ID-shape is its own + 400-level test where noted). +6. **Dependency-cycle constructibility.** `depends_on` is only ever supplied + at creation time (`kt_create_track`, `kt_create_item`), and it may only + reference IDs that already exist at call time — there is no tool that + edits an existing track's or item's dependency list afterward. Under + normal single-threaded use this makes the dependency graph acyclic by + construction (edges always point to already-created, hence + topologically-earlier, nodes). The cycle-rejection requirement therefore + matters most as a **defense-in-depth guard**, exercised in practice by: + (a) a client that predicts or has otherwise obtained an ID before its + owning create call is acknowledged (sequential/derivable IDs, a leaked ID + from a failed/retried call, or a test harness with direct access to the + ID-allocation step) attempting to close a loop back onto it; and (b) two + concurrent create calls racing to reference each other's in-flight IDs + (see CYC-07/CYC-08, which double as concurrency tests). The cycle tests + below (CYC-01..CYC-06) describe the required contract in the conventional + self/2-node/3-node shape; where a given deployment's ID scheme makes a + case impossible to reach purely through the public MCP surface, it must + still be verified against the underlying dependency-validation routine + directly (unit/integration level below the MCP boundary) — the 409 + contract does not become optional just because black-box reachability is + hard. +7. **"Never writes" assertions** (`kt_get_next_steps`, `kt_render_roadmap`) + are checked by comparing a full DB snapshot (or at minimum: row counts + for tracks/items/events/decisions, every `updated_at`, and the drift-flag + set) taken immediately before and immediately after the call — they must + be byte-identical. +8. **Adapter credential leakage** is checked by taking the complete raw JSON + response body of a tool call and asserting the configured adapter + credential value (and any substring of it ≥ 8 chars) does not appear + anywhere in it, including nested in `recent_events`, `dependency_graph`, + error messages, or `source_ref` echoes. +9. Test IDs are grouped by prefix: `AUTH-*` (cross-cutting auth), + `REG/STAT/LTRK/GTRK/NEXT/CTRK/CITM/SESS/DEC/UIST/CDRF/ROAD/GHSY/LNSY-*` + (one prefix per tool, in the order given in the spec), `CYC-*` + (dependency cycles), `CONC-*` (concurrency), `DRIFT-*` (drift-detection + semantics, as distinct from the `kt_check_drift` tool's own request/response + contract which lives under `CDRF-*`), and `ADAPT-*` (adapter behavior and + credential-leakage sweep). + +--- + +## 1. Cross-Cutting Auth Tests + +These patterns apply identically to **all 14 tools**. This section is the +canonical, exhaustive matrix; the per-tool sections below include only the +one or two auth rows most relevant to that tool's shape, and reference this +section for the rest — a full test suite replicates AUTH-01..AUTH-08 against +every tool, not just the representative tool shown here (`kt_get_project_status`). + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| AUTH-01 | All tools (rep.: `kt_get_project_status`) | Positive | Valid project exists; caller holds the token that created it | Valid bearer token in `Authorization` header; valid `project_id` | 200; normal payload returned | +| AUTH-02 | All tools | Negative | Valid project exists | No `Authorization` header at all | 401; no payload data leaked (no project fields in error body) | +| AUTH-03 | All tools | Negative | Valid project exists | `Authorization: Bearer ` with empty token string | 401 | +| AUTH-04 | All tools | Negative | Valid project exists | Malformed token (random non-JWT/non-opaque garbage string, e.g. `"Bearer lol"`) | 401; server does not throw/500 on parse failure | +| AUTH-05 | All tools | Negative | A token that was valid but has since passed its expiry timestamp | Expired bearer token | 401 (not 200 with stale claims) | +| AUTH-06 | All tools | Negative | A token that was valid but has been explicitly revoked (e.g. project deleted, token rotated) | Revoked bearer token | 401 | +| AUTH-07 | All tools | Negative | Token is invalid AND the referenced `project_id` does not exist either | Invalid/expired token + nonexistent `project_id` | 401 (auth check short-circuits before existence check — never 404) | +| AUTH-08 | All tools | Negative | Two projects P1 (token T1) and P2 (token T2) both exist | Call with token T2 against P1's `project_id` | 404 (per Convention #1) — verify this is consistent across all 14 tools and all ID kinds (project/track/item), not just project-level | +| AUTH-09 | All tools | Negative | Valid project exists | Bearer token belonging to a *different, entirely unrelated* deployment/tenant format (e.g. right shape, signed by a different key) | 401, not 404 — signature/issuer invalidity is an auth failure, not a scoping failure | + +--- + +## 2. `kt_register_project` + +`kt_register_project(name, source_type: github|linear|local, source_ref, adapters?) -> {project_id}` + +Note on (d)/(e) from the task brief: this tool creates a project rather than +referencing one, so "nonexistent project" and "cross-tenant project" don't +apply to its own input the way they do to the other 12 tools. REG-08/REG-09 +below substitute the closest meaningful analogues (an unreachable/invalid +`source_ref`, and adapter config that references credentials for a different +tenant) so the tool isn't left with a gap. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| REG-01 | kt_register_project | Positive | None | `name="Storefront Revamp"`, `source_type="github"`, `source_ref="org/repo"` | 200/201; `{project_id}` returned, non-empty, unique | +| REG-02 | kt_register_project | Positive | None | `source_type="linear"`, `source_ref=""` | 200/201; `{project_id}` returned | +| REG-03 | kt_register_project | Positive | None | `source_type="local"`, `source_ref="/abs/path"` | 200/201; `{project_id}` returned | +| REG-04 | kt_register_project | Positive | None | Valid required fields + `adapters={github:{token:"..."}}` | 200/201; `{project_id}` returned; response body contains no adapter token (see ADAPT-05) | +| REG-05 | kt_register_project | Negative | None | `name` omitted | 400; error identifies `name` as missing | +| REG-06 | kt_register_project | Negative | None | `name=""` (empty string) | 400 | +| REG-07 | kt_register_project | Negative | None | `source_type="gitlab"` (not in `github\|linear\|local` enum) | 400; error names the invalid enum value | +| REG-08 | kt_register_project | Negative | None | `source_type` omitted entirely | 400 | +| REG-09 | kt_register_project | Negative | None | `source_ref` omitted | 400 | +| REG-10 | kt_register_project | Negative | None | `source_type="github"`, `source_ref=""` | 400 | +| REG-11 | kt_register_project | Negative | None | `adapters` malformed (e.g. `adapters="not-an-object"`) | 400; does not silently drop the field and succeed | +| REG-12 | kt_register_project | Negative | None | `adapters={slack:{...}}` — an adapter kind KnoTrack doesn't support | 400 (unknown adapter kind) rather than silently accepted and later failing opaquely at sync time | +| REG-13 | kt_register_project | Negative (auth) | None | Missing bearer token | 401 | +| REG-14 | kt_register_project | Negative (auth) | None | Malformed bearer token | 401 | +| REG-15 | kt_register_project | Positive | None | Two calls with identical `name`/`source_type`/`source_ref` | Both succeed with two distinct `project_id`s (no implicit uniqueness constraint on name) — or, if the implementation *does* enforce uniqueness, the second call returns a defined 409/400 rather than silently returning the first project's ID. Pick one behavior and assert it consistently. | + +--- + +## 3. `kt_get_project_status` + +`kt_get_project_status(project_id) -> {tracks, drift_flags, recent_events}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| STAT-01 | kt_get_project_status | Positive | Project exists with ≥2 tracks, ≥1 drift flag, ≥1 event | Valid token + `project_id` | 200; `tracks`, `drift_flags`, `recent_events` all populated and consistent with DB state | +| STAT-02 | kt_get_project_status | Positive | Freshly registered project, no tracks/events yet | Valid token + `project_id` | 200; `tracks=[]`, `drift_flags=[]`, `recent_events=[]` (empty, not null/error) | +| STAT-03 | kt_get_project_status | Negative | None | `project_id` omitted | 400 | +| STAT-04 | kt_get_project_status | Negative | None | `project_id` is a syntactically malformed ID (wrong shape) | 400 (distinct from 404 — bad shape vs. absent record) | +| STAT-05 | kt_get_project_status | Negative (auth) | Project exists | Missing bearer token | 401 | +| STAT-06 | kt_get_project_status | Negative (auth) | Project exists | Invalid/garbage bearer token | 401 | +| STAT-07 | kt_get_project_status | Negative | None | Well-formed but never-issued `project_id` | 404 | +| STAT-08 | kt_get_project_status | Negative | Project P1 (token T1) and P2 (token T2) exist | Token T2, `project_id` = P1's | 404 (Convention #1) | +| STAT-09 | kt_get_project_status | Negative | Project has a github adapter configured with a credential | Valid call | 200; response body contains no adapter credential anywhere (see ADAPT-05) | + +--- + +## 4. `kt_list_tracks` + +`kt_list_tracks(project_id, status?) -> {tracks}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| LTRK-01 | kt_list_tracks | Positive | Project has tracks in mixed statuses | `project_id` only, no `status` filter | 200; all tracks for the project returned | +| LTRK-02 | kt_list_tracks | Positive | Project has tracks in mixed statuses | `status="in_progress"` (or whatever the track-status enum defines) | 200; only tracks matching that status returned | +| LTRK-03 | kt_list_tracks | Positive | Project has zero tracks matching a given status | `status="blocked"` where none are blocked | 200; `tracks=[]` | +| LTRK-04 | kt_list_tracks | Negative | Project exists | `status="not-a-real-status"` | 400 (invalid enum value) | +| LTRK-05 | kt_list_tracks | Negative | None | `project_id` omitted | 400 | +| LTRK-06 | kt_list_tracks | Negative (auth) | Project exists | Missing token | 401 | +| LTRK-07 | kt_list_tracks | Negative | None | Nonexistent `project_id` | 404 | +| LTRK-08 | kt_list_tracks | Negative | Two projects, two tokens | Token for P2, `project_id` = P1 | 404 | +| LTRK-09 | kt_list_tracks | Positive | Project has tracks each with dependencies on other tracks | No filter | 200; `tracks` list does not itself need to include full dependency graphs (that's `kt_get_track`'s job) but each track's own summary fields are internally consistent with `kt_get_track` for the same ID | + +--- + +## 5. `kt_get_track` + +`kt_get_track(project_id, track_id) -> {track, items, dependency_graph}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| GTRK-01 | kt_get_track | Positive | Track exists with items and a dependency edge to another track | Valid `project_id` + `track_id` | 200; `track`, `items`, `dependency_graph` all returned and consistent with DB | +| GTRK-02 | kt_get_track | Positive | Track exists with zero items | Valid IDs | 200; `items=[]`, `dependency_graph` reflects track-level deps only | +| GTRK-03 | kt_get_track | Negative | Project exists | `track_id` omitted | 400 | +| GTRK-04 | kt_get_track | Negative | None | `project_id` omitted | 400 | +| GTRK-05 | kt_get_track | Negative (auth) | Track exists | Missing token | 401 | +| GTRK-06 | kt_get_track | Negative | Project exists | Nonexistent `project_id`, any `track_id` | 404 | +| GTRK-07 | kt_get_track | Negative | Project exists | Valid `project_id`, nonexistent `track_id` | 404 | +| GTRK-08 | kt_get_track | Negative | Two projects, two tokens | Token for P2, correct `project_id`=P2 but `track_id` belongs to P1 | 404 (track doesn't belong to this project even though the project_id itself is valid and owned by the caller) | +| GTRK-09 | kt_get_track | Negative | Two projects, two tokens | Token for P2, `project_id`=P1, `track_id` belongs to P1 | 404 (Convention #1, project-level mismatch) | +| GTRK-10 | kt_get_track | Positive | Track has a chain of 3 dependent tracks (A→B→C) | Get track C | 200; `dependency_graph` correctly shows the full ancestor chain, not just direct parent | + +--- + +## 6. `kt_get_next_steps` (tool-contract rows; see §12 for the dedicated advisory/no-write test set) + +`kt_get_next_steps(project_id) -> {recommended_items}` — ADVISORY ONLY, must never write/assign anything. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| NEXT-01 | kt_get_next_steps | Positive | Project has unblocked items available | Valid `project_id` | 200; `recommended_items` populated | +| NEXT-02 | kt_get_next_steps | Negative | None | `project_id` omitted | 400 | +| NEXT-03 | kt_get_next_steps | Negative (auth) | Project exists | Missing token | 401 | +| NEXT-04 | kt_get_next_steps | Negative | None | Nonexistent `project_id` | 404 | +| NEXT-05 | kt_get_next_steps | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | + +(Full behavioral coverage — unblocked-only filtering, empty-list cases, +never-writes assertion — is in §12, since the task calls these out as a +dedicated set distinct from the basic per-tool contract above.) + +--- + +## 7. `kt_create_track` + +`kt_create_track(project_id, title, depends_on?, source_doc_ref?) -> {track_id}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| CTRK-01 | kt_create_track | Positive | Project exists | `title="Auth rework"`, no `depends_on`, no `source_doc_ref` | 200/201; `{track_id}` returned | +| CTRK-02 | kt_create_track | Positive | Project has an existing track A | `title="Follow-up"`, `depends_on=[A]` | 200/201; `{track_id}`; `kt_get_track` on the new track shows A in `dependency_graph` | +| CTRK-03 | kt_create_track | Positive | Project exists | `title="Docs pass"`, `source_doc_ref="docs/spec.md#section-2"` | 200/201; `source_doc_ref` retrievable via `kt_get_track` | +| CTRK-04 | kt_create_track | Negative | Project exists | `title` omitted | 400 | +| CTRK-05 | kt_create_track | Negative | Project exists | `title=""` | 400 | +| CTRK-06 | kt_create_track | Negative | Project exists | `depends_on=[""]` | 404 or 400 (dangling reference) — must not silently create the track with a broken edge | +| CTRK-07 | kt_create_track | Negative | Project P1 has track A; project P2 exists | Token for P2, `project_id=P2`, `depends_on=[A]` (A belongs to P1) | 404/400 — cross-project dependency edges must be rejected, not silently created (would otherwise leak P1's track ID's existence into P2's graph) | +| CTRK-08 | kt_create_track | Negative | None | `project_id` omitted | 400 | +| CTRK-09 | kt_create_track | Negative (auth) | Project exists | Missing token | 401 | +| CTRK-10 | kt_create_track | Negative | None | Nonexistent `project_id` | 404 | +| CTRK-11 | kt_create_track | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| CTRK-12 | kt_create_track | Negative | Project exists | `depends_on` is not an array (e.g. a bare string) | 400 | +| CTRK-13 | kt_create_track | See CYC-01/03/05 | — | Direct/2-node/3-node track cycles | 409 — cross-referenced in §11 | + +--- + +## 8. `kt_create_item` + +`kt_create_item(project_id, track_id, title, sequence_position?, depends_on?) -> {item_id}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| CITM-01 | kt_create_item | Positive | Track exists | `title="Write migration"`, no optional fields | 200/201; `{item_id}` returned; sequence_position auto-assigned (e.g. appended to end) | +| CITM-02 | kt_create_item | Positive | Track has items at positions 1,2,3 | `title="Insert here"`, `sequence_position=2` | 200/201; new item takes position 2; existing items at ≥2 shift consistently (verify via `kt_get_track`) | +| CITM-03 | kt_create_item | Positive | Track has item X | `title="Depends on X"`, `depends_on=[X]` | 200/201; `dependency_graph` for the track shows the edge | +| CITM-04 | kt_create_item | Negative | Track exists | `title` omitted | 400 | +| CITM-05 | kt_create_item | Negative | Track exists | `title=""` | 400 | +| CITM-06 | kt_create_item | Negative | Track exists | `sequence_position=-1` | 400 | +| CITM-07 | kt_create_item | Negative | Track exists | `sequence_position="two"` (wrong type) | 400 | +| CITM-08 | kt_create_item | Negative | Track exists | `depends_on=[""]` | 404 NOT_FOUND — the id doesn't exist as an item at all | +| CITM-09 | kt_create_item | Negative | Track T1 has item X; track T2 exists in the same project | `track_id=T2`, `depends_on=[X]` (X belongs to T1) | 400 VALIDATION — cross-track item dependencies are not allowed; `depends_on` must belong to the same track as the item being created (docs/PRD.md §4.7) | +| CITM-10 | kt_create_item | Negative | Project P1 has item X; project P2 exists | Token for P2, `depends_on=[X]` (X belongs to P1) | 404 NOT_FOUND or 400 VALIDATION — X does not exist as an item in P2's track scope | +| CITM-11 | kt_create_item | Negative | None | `project_id` omitted | 400 | +| CITM-12 | kt_create_item | Negative | Project exists | `track_id` omitted | 400 | +| CITM-13 | kt_create_item | Negative (auth) | Track exists | Missing token | 401 | +| CITM-14 | kt_create_item | Negative | Project exists | Nonexistent `track_id` | 404 | +| CITM-15 | kt_create_item | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| CITM-16 | kt_create_item | Negative | Two projects, two tokens | Token for P2, `project_id=P2`, but `track_id` belongs to P1 | 404 | +| CITM-17 | kt_create_item | See CYC-02/04/06 | — | Direct/2-node/3-node item cycles | 409 — cross-referenced in §11 | + +--- + +## 9. `kt_record_session_summary` + +`kt_record_session_summary(project_id, track_id, summary_text, files_touched[], items_touched[]) -> {event_id, drift_flags_raised}` — +runs a structural drift check inline: flags if an item in `items_touched` has an +undone dependency, or if a file in `files_touched` doesn't map to any item in +the track. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| SESS-01 | kt_record_session_summary | Positive | Track/items exist, all dependencies satisfied, all files map to items | Valid `summary_text`, `files_touched=["src/a.ts"]` (mapped), `items_touched=[item with no undone deps]` | 200; `{event_id}` new & unique; `drift_flags_raised=[]` | +| SESS-02 | kt_record_session_summary | Positive (drift) | Item Y depends on item X; X is not done | `items_touched=[Y]` | 200; `event_id` returned; `drift_flags_raised` contains an "undone dependency" flag referencing X→Y | +| SESS-03 | kt_record_session_summary | Positive (drift) | Track has items, none mapped to `notes/scratch.md` | `files_touched=["notes/scratch.md"]` | 200; `drift_flags_raised` contains an "unmapped file" flag for that path | +| SESS-04 | kt_record_session_summary | Positive (drift) | Both SESS-02 and SESS-03 conditions true simultaneously | `items_touched=[Y]`, `files_touched=["notes/scratch.md"]` | 200; `drift_flags_raised` contains both flags (not just one) | +| SESS-05 | kt_record_session_summary | Positive | `files_touched=[]`, `items_touched=[]` (edge case: a summary with no touched artifacts) | Valid `summary_text` only | 200; `event_id` returned; `drift_flags_raised=[]` — must not error on empty arrays | +| SESS-06 | kt_record_session_summary | Negative | Track exists | `summary_text` omitted | 400 | +| SESS-07 | kt_record_session_summary | Negative | Track exists | `summary_text=""` | 400 | +| SESS-08 | kt_record_session_summary | Negative | Track exists | `files_touched` is not an array (e.g. a string) | 400 | +| SESS-09 | kt_record_session_summary | Negative | Track exists | `items_touched` is not an array | 400 | +| SESS-10 | kt_record_session_summary | Negative | Track exists | `items_touched=[""]` | 404/400 — must not silently drop the unknown ID and succeed | +| SESS-11 | kt_record_session_summary | Negative | None | `project_id` omitted | 400 | +| SESS-12 | kt_record_session_summary | Negative | Project exists | `track_id` omitted | 400 | +| SESS-13 | kt_record_session_summary | Negative (auth) | Track exists | Missing token | 401 | +| SESS-14 | kt_record_session_summary | Negative | Project exists | Nonexistent `track_id` | 404 | +| SESS-15 | kt_record_session_summary | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| SESS-16 | kt_record_session_summary | Negative | Two projects, two tokens | Token for P2, `project_id=P2`, `track_id` belongs to P1 | 404 | +| SESS-17 | kt_record_session_summary | Negative | Project has github adapter with credential configured | Valid call | 200; response contains no adapter credential | +| SESS-18 | kt_record_session_summary | See CONC-01/02 | — | Two simultaneous calls on the same track | No corrupted `sequence_position`, no double-counted event — cross-referenced in §11.5 | + +--- + +## 10. `kt_record_decision` + +`kt_record_decision(project_id, track_id, title, rationale, what_changed) -> {decision_id}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| DEC-01 | kt_record_decision | Positive | Track exists | All fields populated with reasonable text | 200/201; `{decision_id}` returned, unique | +| DEC-02 | kt_record_decision | Negative | Track exists | `title` omitted | 400 | +| DEC-03 | kt_record_decision | Negative | Track exists | `rationale` omitted | 400 | +| DEC-04 | kt_record_decision | Negative | Track exists | `what_changed` omitted | 400 | +| DEC-05 | kt_record_decision | Negative | Track exists | `title=""` | 400 | +| DEC-06 | kt_record_decision | Negative | None | `project_id` omitted | 400 | +| DEC-07 | kt_record_decision | Negative | Project exists | `track_id` omitted | 400 | +| DEC-08 | kt_record_decision | Negative (auth) | Track exists | Missing token | 401 | +| DEC-09 | kt_record_decision | Negative | Project exists | Nonexistent `track_id` | 404 | +| DEC-10 | kt_record_decision | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| DEC-11 | kt_record_decision | Negative | Two projects, two tokens | Token for P2, `project_id=P2`, `track_id` belongs to P1 | 404 | +| DEC-12 | kt_record_decision | Positive | Decision recorded on track T | Subsequent `kt_get_project_status` / `kt_get_track` call | The decision surfaces appropriately in project history/events (verify it is durably persisted, not just acknowledged) | + +--- + +## 11. `kt_update_item_status` + +`kt_update_item_status(project_id, item_id, status: pending|in_progress|done|blocked) -> {ok}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| UIST-01 | kt_update_item_status | Positive | Item exists, `status=pending` | `status="in_progress"` | 200; `{ok:true}`; `kt_get_track` reflects new status | +| UIST-02 | kt_update_item_status | Positive | Item `in_progress` | `status="done"` | 200; `{ok:true}` | +| UIST-03 | kt_update_item_status | Positive | Item exists | `status="blocked"` | 200; `{ok:true}` | +| UIST-04 | kt_update_item_status | Positive | Item exists | `status="pending"` (reset) | 200; `{ok:true}` — no restriction on backward transitions unless spec says otherwise | +| UIST-05 | kt_update_item_status | Positive (edge) | Item Y depends on item X; X is still `pending` | `item_id=Y`, `status="done"` | 200; `{ok:true}` — the tool itself does not gate on dependency completion (only `kt_record_session_summary`'s inline check and `kt_check_drift` do); a subsequent `kt_check_drift` call MAY surface this as a flag depending on drift rules, but `kt_update_item_status` itself must not reject or silently no-op | +| UIST-06 | kt_update_item_status | Negative | Item exists | `status="cancelled"` (not in enum) | 400 | +| UIST-07 | kt_update_item_status | Negative | Item exists | `status` omitted | 400 | +| UIST-08 | kt_update_item_status | Negative | None | `item_id` omitted | 400 | +| UIST-09 | kt_update_item_status | Negative | None | `project_id` omitted | 400 | +| UIST-10 | kt_update_item_status | Negative (auth) | Item exists | Missing token | 401 | +| UIST-11 | kt_update_item_status | Negative | Project exists | Nonexistent `item_id` | 404 | +| UIST-12 | kt_update_item_status | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| UIST-13 | kt_update_item_status | Negative | Two projects, two tokens | Token for P2, `project_id=P2`, `item_id` belongs to P1 | 404 | + +--- + +## 11.5 `kt_check_drift` (tool contract; see §13 for drift-detection semantics) + +`kt_check_drift(project_id) -> {flags}` + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| CDRF-01 | kt_check_drift | Positive | Project has no drift conditions present | Valid `project_id` | 200; `flags=[]` | +| CDRF-02 | kt_check_drift | Positive | Project has ≥1 active drift condition (e.g. undone-dependency touch) | Valid `project_id` | 200; `flags` non-empty, matching the actual condition | +| CDRF-03 | kt_check_drift | Negative | None | `project_id` omitted | 400 | +| CDRF-04 | kt_check_drift | Negative (auth) | Project exists | Missing token | 401 | +| CDRF-05 | kt_check_drift | Negative | None | Nonexistent `project_id` | 404 | +| CDRF-06 | kt_check_drift | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| CDRF-07 | kt_check_drift | Negative | Project has adapter credential configured | Valid call | 200; no credential present in response | + +--- + +## 12. `kt_get_next_steps` — dedicated advisory / no-write test set + +Per the task's requirement for dedicated depth beyond the basic tool +contract in §6. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| NEXT-10 | kt_get_next_steps | Positive | Track has items: A (done), B (blocked), C (pending, no deps), D (pending, depends on A/done) | Valid `project_id` | 200; `recommended_items` includes C and D; excludes A (done) and B (blocked) | +| NEXT-11 | kt_get_next_steps | Positive | Item E is pending but depends on item F which is not done | Valid `project_id` | 200; E is excluded from `recommended_items` (dependency not satisfied even though E's own status is "pending" not "blocked") | +| NEXT-12 | kt_get_next_steps | Positive | Every item in the project is either `done` or `blocked` | Valid `project_id` | 200; `recommended_items=[]` (not an error, not null) | +| NEXT-13 | kt_get_next_steps | Positive | Project has zero items at all | Valid `project_id` | 200; `recommended_items=[]` | +| NEXT-14 | kt_get_next_steps | Negative (explicit no-write assertion) | Full DB snapshot taken (row counts, `updated_at` timestamps, event log, drift flags, item statuses) | Call `kt_get_next_steps` | 200 with recommendations; post-call snapshot is **byte-identical** to pre-call snapshot — specifically: no new `event_id`, no item status changed, no drift flag added/removed, no track/item row touched | +| NEXT-15 | kt_get_next_steps | Negative (explicit no-write assertion) | Same as NEXT-14, called twice in a row | Call twice | Both calls return identical `recommended_items`; zero writes on either call; calling it does not itself "consume" or de-prioritize a recommendation | +| NEXT-16 | kt_get_next_steps | Positive | Track has an item chain A→B→C where A is done, B is in_progress, C depends on B | Valid `project_id` | 200; B appears (unblocked: its only dep A is done); C does not appear (its dep B is not done) | + +--- + +## 13. Drift-Detection Semantics + +Distinct from `kt_check_drift`'s own request/response contract (§11.5), this +section tests the *detection logic itself*, which is triggered both inline by +`kt_record_session_summary` and on-demand by `kt_check_drift`. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| DRIFT-01 | Drift detection | Positive | Item Y (in `items_touched`) depends on item X; X is `pending` | `kt_record_session_summary(items_touched=[Y], ...)` | `drift_flags_raised` includes an undone-dependency flag for X→Y; the same flag then appears in the next `kt_check_drift` call | +| DRIFT-02 | Drift detection | Positive | File `db/schema.sql` touched, no item in the track maps to it | `kt_record_session_summary(files_touched=["db/schema.sql"], ...)` | `drift_flags_raised` includes an unmapped-file flag for `db/schema.sql`; also visible in next `kt_check_drift` | +| DRIFT-03 | Drift detection | Positive (no flag) | Item Z has all dependencies `done`; every file touched maps to an item in the track | `kt_record_session_summary(items_touched=[Z], files_touched=[mapped files], ...)` | `drift_flags_raised=[]`; `kt_check_drift` immediately after also returns no new flag for this session | +| DRIFT-04 | Drift detection | Positive (resolution) | DRIFT-01's flag is active; dependency X is subsequently marked `done` via `kt_update_item_status` | Call `kt_check_drift` again | The undone-dependency flag for X→Y no longer appears — it does not persist as a stale/ghost flag once its triggering condition is resolved | +| DRIFT-05 | Drift detection | Positive (no reappearance) | DRIFT-04's flag was resolved; no new session touches Y or any dependency of Y | Call `kt_check_drift` repeatedly | The resolved flag never reappears absent the same condition recurring | +| DRIFT-06 | Drift detection | Positive (recurrence) | DRIFT-04's flag was resolved; item Y is later touched again while depending on a *different*, still-undone item W | `kt_record_session_summary(items_touched=[Y], ...)` where Y now depends on undone W | A new flag is raised for W→Y — this is a legitimately recurring condition (same item, different cause), not a suppressed duplicate | +| DRIFT-07 | Drift detection | Positive (multi-flag) | Item touched has an undone dependency AND a file touched is unmapped, in the same call | Single `kt_record_session_summary` call | Both flags appear in `drift_flags_raised`, and both persist to the next `kt_check_drift` | +| DRIFT-08 | Drift detection | Positive (idempotent check) | A flag is currently active | Call `kt_check_drift` twice in a row with no state change between | Both calls return the identical flag set (no duplication of the same flag on repeated checks) | +| DRIFT-09 | Drift detection | Positive (scope) | Two tracks in the same project; drift condition exists only in track A | `kt_check_drift(project_id)` (project-scoped, not track-scoped per the tool signature) | Returned `flags` are correctly attributed to track A only; track B's flags list (if flags carry track attribution) does not falsely include A's condition | + +--- + +## 14. `kt_render_roadmap` + +`kt_render_roadmap(project_id, format?) -> {content}` — pure function, must never write to the DB. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| ROAD-01 | kt_render_roadmap | Positive | Project has tracks/items in various states | `project_id` only, default `format` | 200; `content` returned, non-empty, reflects current tracks/items | +| ROAD-02 | kt_render_roadmap | Positive | Project exists | `format="markdown"` (or whichever formats are documented) | 200; `content` in the requested format | +| ROAD-03 | kt_render_roadmap | Negative | Project exists | `format="pdf"` (unsupported/undocumented format) | 400; error names the invalid format, does not silently fall back | +| ROAD-04 | kt_render_roadmap | Negative | None | `project_id` omitted | 400 | +| ROAD-05 | kt_render_roadmap | Negative (auth) | Project exists | Missing token | 401 | +| ROAD-06 | kt_render_roadmap | Negative | None | Nonexistent `project_id` | 404 | +| ROAD-07 | kt_render_roadmap | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| ROAD-08 | kt_render_roadmap | Positive (exact reflection) | Baseline roadmap rendered; then a new item is created via `kt_create_item` | Render roadmap again | New `content` includes the new item; differs from the baseline exactly where the DB differs and nowhere else | +| ROAD-09 | kt_render_roadmap | Positive (determinism) | No DB changes between calls | Call `kt_render_roadmap` twice in a row with identical arguments | Both calls return **byte-identical** `content` | +| ROAD-10 | kt_render_roadmap | Negative (explicit no-write assertion) | Full DB snapshot taken pre-call | Call `kt_render_roadmap` | Post-call snapshot is byte-identical to pre-call snapshot — no event logged, no `updated_at` touched, no drift flag created as a side effect of rendering | +| ROAD-11 | kt_render_roadmap | Negative | Project has adapter credential configured | Valid call | `content` contains no adapter credential, even if the roadmap text references the source repo/linear project | + +--- + +## 15. `kt_sync_to_github` + +`kt_sync_to_github(project_id, track_id) -> {ok} | {ok:false, error}` — only valid if a github adapter is configured for the project. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| GHSY-01 | kt_sync_to_github | Positive | Project has a working github adapter configured; track exists | Valid `project_id`, `track_id` | 200; `{ok:true}` (or documented success payload); no credential in response | +| GHSY-02 | kt_sync_to_github | Negative (clean error, not crash) | Project has **no** github adapter configured | Valid `project_id`, `track_id` | 200 (tool-level) with `{ok:false, error:"..."}` — NOT a 500, NOT an unhandled exception, NOT a generic 400 that hides the real cause | +| GHSY-03 | kt_sync_to_github | Negative | Project has a linear adapter but no github adapter | Same call | `{ok:false, error:"..."}` clearly indicating github specifically is not configured, not a generic failure | +| GHSY-04 | kt_sync_to_github | Negative | github adapter configured but its credential is invalid/expired at the remote end | Same call | `{ok:false, error:"..."}` — the remote auth failure is caught and surfaced cleanly, not a raw stack trace or crash | +| GHSY-05 | kt_sync_to_github | Negative | github adapter configured; remote GitHub API is unreachable/times out | Same call | `{ok:false, error:"..."}` — network failure handled gracefully | +| GHSY-06 | kt_sync_to_github | Negative | None | `track_id` omitted | 400 | +| GHSY-07 | kt_sync_to_github | Negative | None | `project_id` omitted | 400 | +| GHSY-08 | kt_sync_to_github | Negative (auth) | Adapter configured | Missing token | 401 | +| GHSY-09 | kt_sync_to_github | Negative | None | Nonexistent `project_id` | 404 | +| GHSY-10 | kt_sync_to_github | Negative | Project exists, adapter configured | Nonexistent `track_id` | 404 | +| GHSY-11 | kt_sync_to_github | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| GHSY-12 | kt_sync_to_github | Negative | Two projects, two tokens | Token for P2 (P2 has github adapter), `project_id=P2`, `track_id` belongs to P1 | 404 | +| GHSY-13 | kt_sync_to_github | Negative | github adapter configured with credential | Successful sync call (GHSY-01) | The success response body itself contains no credential — verify separately from the general adapter sweep in §16, since a successful sync is the highest-risk path for accidentally echoing adapter config back | + +--- + +## 16. `kt_sync_to_linear` + +`kt_sync_to_linear(project_id, track_id) -> {ok} | {ok:false, error}` — only valid if a linear adapter is configured. Mirror of §15. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| LNSY-01 | kt_sync_to_linear | Positive | Project has a working linear adapter configured; track exists | Valid `project_id`, `track_id` | 200; `{ok:true}`; no credential in response | +| LNSY-02 | kt_sync_to_linear | Negative (clean error, not crash) | Project has **no** linear adapter configured | Valid `project_id`, `track_id` | `{ok:false, error:"..."}`, not a crash/500 | +| LNSY-03 | kt_sync_to_linear | Negative | Project has a github adapter but no linear adapter | Same call | `{ok:false, error:"..."}` naming linear specifically | +| LNSY-04 | kt_sync_to_linear | Negative | linear adapter configured but credential invalid/expired | Same call | `{ok:false, error:"..."}` clean | +| LNSY-05 | kt_sync_to_linear | Negative | linear adapter configured; remote Linear API unreachable/times out | Same call | `{ok:false, error:"..."}` clean | +| LNSY-06 | kt_sync_to_linear | Negative | None | `track_id` omitted | 400 | +| LNSY-07 | kt_sync_to_linear | Negative | None | `project_id` omitted | 400 | +| LNSY-08 | kt_sync_to_linear | Negative (auth) | Adapter configured | Missing token | 401 | +| LNSY-09 | kt_sync_to_linear | Negative | None | Nonexistent `project_id` | 404 | +| LNSY-10 | kt_sync_to_linear | Negative | Project exists, adapter configured | Nonexistent `track_id` | 404 | +| LNSY-11 | kt_sync_to_linear | Negative | Two projects, two tokens | Wrong-tenant token/project pairing | 404 | +| LNSY-12 | kt_sync_to_linear | Negative | Two projects, two tokens | Token for P2 (P2 has linear adapter), `project_id=P2`, `track_id` belongs to P1 | 404 | +| LNSY-13 | kt_sync_to_linear | Negative | linear adapter configured with credential | Successful sync call (LNSY-01) | Success response contains no credential | + +--- + +## 17. Dependency-Cycle Tests + +See Convention #6 for how these are constructed/verified where the black-box +API alone makes an exact scenario hard to force. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| CYC-01 | kt_create_track | Negative | None | Attempt to create a track whose `depends_on` includes its own (about-to-be-assigned) ID — direct self-dependency | 409; no track created; if unreachable via the public tool surface given the deployment's ID scheme, verified instead at the dependency-validation unit level per Convention #6 | +| CYC-02 | kt_create_item | Negative | Track exists | Attempt to create an item whose `depends_on` includes its own about-to-be-assigned ID | 409; no item created (same fallback-verification note as CYC-01) | +| CYC-03 | kt_create_track | Negative | Tracks A and B exist such that A already depends on B (A created after B, `depends_on=[B]`) | Attempt an operation that would make B depend on A, closing a 2-node cycle A→B→A | 409; no new edge/record persisted; A's and B's existing dependency data unchanged | +| CYC-04 | kt_create_item | Negative | Items X and Y exist such that Y already depends on X | Attempt an operation that would make X depend on Y, closing a 2-node cycle | 409; no new edge persisted | +| CYC-05 | kt_create_track | Negative | Tracks A, B, C exist such that A depends on B, and B depends on C (chain A→B→C) | Attempt an operation that would make C depend on A, closing the 3-node transitive cycle A→B→C→A | 409; no new edge persisted; the existing A→B→C chain remains intact and unaffected | +| CYC-06 | kt_create_item | Negative | Items P, Q, R exist in a chain P→Q→R | Attempt to close R→P, forming a 3-node transitive cycle | 409; no new edge persisted | +| CYC-07 | kt_create_track (concurrency variant) | Negative | Two tracks are about to be created, each intending to depend on the other's soon-to-exist ID (e.g. both IDs pre-allocated/known to the test harness) | Fire both `kt_create_track` calls concurrently, each with `depends_on=[the other's ID]` | At most one of the two succeeds (creating a valid one-directional edge to an existing track); the other is rejected with 409, OR both are rejected with 409 if neither ID existed yet at the other's validation time — in no case do both succeed and leave a 2-node cycle in the DB | +| CYC-08 | kt_create_item (concurrency variant) | Negative | Two items about to be created, mirroring CYC-07 at the item level, same track | Fire both `kt_create_item` calls concurrently with mutual `depends_on` | Same guarantee as CYC-07: never both succeed into a stored cycle | +| CYC-09 | kt_get_track | Positive (regression guard) | A legitimate long dependency chain exists (5+ tracks, strictly acyclic) | `kt_get_track` on the last track in the chain | 200; full chain returned correctly — confirms cycle rejection logic hasn't become over-aggressive and started rejecting valid deep chains | + +--- + +## 18. Concurrency Tests + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| CONC-01 | kt_record_session_summary | Positive (race) | Track T exists with items | Two `kt_record_session_summary` calls fired simultaneously against the same `track_id`, each with distinct `summary_text`/`files_touched`/`items_touched` | Both calls succeed; exactly two distinct `event_id`s are returned (not one reused, not a duplicate); the event log for T contains exactly 2 new entries, not 0, 1, or 3+ | +| CONC-02 | kt_record_session_summary | Positive (race) | Same as CONC-01 | Same concurrent calls | Any shared derived state each summary touches (e.g. a running `sequence_position` counter on the track, if session summaries affect one) ends in a valid, non-corrupted state — no duplicate position assigned to two different records, no gap introduced, no lost update where one call's effect silently overwrites the other's | +| CONC-03 | kt_create_item | Positive (race) | Track T exists with 3 items at positions 1–3 | Two `kt_create_item` calls fired concurrently, both omitting `sequence_position` (auto-append) | Both items are created; final positions are unique and sequential (e.g. 4 and 5, in either order) — never both landing on 4, never a gap at 4 | +| CONC-04 | kt_update_item_status | Positive (race) | Item exists, `status=pending` | Two concurrent `kt_update_item_status` calls on the same item with different target statuses (`in_progress` and `blocked`) | Both calls return `{ok:true}`; the item ends in exactly one of the two statuses (last-write-wins or documented conflict policy) — never a corrupted/undefined status, never silently ignoring both | +| CONC-05 | kt_check_drift | Positive (race) | A drift condition is active | Two concurrent `kt_check_drift` calls | Both return the same flag set; no duplicate flags are created as a side effect of concurrent checking | +| CONC-06 | kt_record_session_summary + kt_update_item_status | Positive (race, cross-tool) | Item X is item Y's dependency, X is `pending` | Concurrently: call A marks X `done` via `kt_update_item_status`; call B runs `kt_record_session_summary(items_touched=[Y])` | The drift flag for Y depends on the actual commit order (X-done-before-summary → no flag; summary-before-X-done → flag raised); whichever order wins, the result is internally consistent — no flag is raised AND the dependency shown as done simultaneously in a way that contradicts `kt_get_track`'s own state | +| CONC-07 | kt_render_roadmap | Positive (race) | Roadmap is being rendered while a concurrent `kt_create_item` commits | Concurrent `kt_render_roadmap` and `kt_create_item` calls | `kt_render_roadmap` returns a roadmap that reflects either the pre- or post-create state cleanly (a consistent snapshot) — never a torn/partial read (e.g. an item listed without its track, or a track total count that doesn't match its listed items) | + +--- + +## 19. Adapter Tests + +Covers the two sync tools' error handling plus the credential-leakage sweep +that applies across all 14 tools. + +| Test ID | Tool/Area | Type | Preconditions | Input | Expected Result | +|---|---|---|---|---|---| +| ADAPT-01 | kt_sync_to_github | Negative | No github adapter configured | Call `kt_sync_to_github` | `{ok:false, error:"..."}`, HTTP 200 at the transport level (tool-level failure, not transport failure) or a documented 4xx — but never a 500/unhandled exception (duplicate of GHSY-02, listed here for the adapter-section completeness the task calls for) | +| ADAPT-02 | kt_sync_to_linear | Negative | No linear adapter configured | Call `kt_sync_to_linear` | `{ok:false, error:"..."}`, clean (duplicate of LNSY-02) | +| ADAPT-03 | kt_sync_to_github | Negative | Neither adapter configured at all | Call `kt_sync_to_github` | `{ok:false, error:"..."}` — same clean failure even in the "no adapters of any kind" case, not a different/worse error path | +| ADAPT-04 | kt_sync_to_linear | Negative | Neither adapter configured at all | Call `kt_sync_to_linear` | `{ok:false, error:"..."}` clean | +| ADAPT-05 | **All 14 tools** | Negative (credential-leakage sweep) | Project registered with `adapters={github:{token:"ghp_SECRETVALUE..."}, linear:{token:"lin_SECRETVALUE..."}}` | Run one representative successful call to each of the 14 tools against this project (register, get_status, list_tracks, get_track, get_next_steps, create_track, create_item, record_session_summary, record_decision, update_item_status, check_drift, render_roadmap, sync_to_github, sync_to_linear) | For every single response body, the raw JSON contains neither `ghp_SECRETVALUE...` nor `lin_SECRETVALUE...` nor any ≥8-character substring of either, in any field including nested objects, error messages, and echoed `source_ref`/`adapters` structures | +| ADAPT-06 | kt_get_project_status | Negative (credential-leakage, error path) | Adapter credential configured; then trigger an internal error path if one exists (e.g. malformed downstream state) | Call that surfaces an error | Even error responses/stack traces (if any are exposed) contain no credential material | +| ADAPT-07 | kt_register_project | Negative (credential-leakage, at creation) | None | Register a project with adapter credentials | The `{project_id}` response itself contains no credential echo, not even partially masked-but-derivable (e.g. not last-4-plus-length in a way that narrows brute force meaningfully beyond what's operationally necessary) | +| ADAPT-08 | kt_sync_to_github / kt_sync_to_linear | Negative | Adapter configured for github only; caller calls `kt_sync_to_linear` | Call `kt_sync_to_linear` | `{ok:false, error:"..."}` — must not fall back to or accidentally use the github adapter, and must not error in a way that reveals whether a *different* adapter is configured beyond what's necessary | +| ADAPT-09 | kt_sync_to_github | Positive → then Negative | github adapter configured and working; sync succeeds once | Immediately revoke/invalidate the credential at the remote end, then call `kt_sync_to_github` again | Second call returns `{ok:false, error:"..."}` cleanly; does not crash, does not return a stale cached `{ok:true}` | + +--- + +## Coverage Checklist + +For traceability against the task brief: + +- [x] Every tool: happy path, missing/invalid field, missing/invalid auth, nonexistent-reference 404, cross-tenant-reference 404 (§2–§16, with the §2 note on `kt_register_project`'s inapplicable rows) +- [x] Cross-cutting auth matrix: valid, missing, malformed, expired, revoked, wrong-project-scope, wrong-issuer (§1) +- [x] Dependency cycles: direct self, 2-node, 3-node, both tracks and items, plus concurrent-race variants (§17) +- [x] Concurrency: dual `kt_record_session_summary` on same track — no corrupted `sequence_position`, no double-counted event (§18, CONC-01/02), plus additional concurrency surfaces +- [x] Drift detection: undone-dependency flag, unmapped-file flag, no-flag-on-clean-work, resolved-flag-doesn't-reappear-unless-recurring (§13) +- [x] `kt_get_next_steps`: unblocked-only, empty-when-all-blocked-or-done, explicit never-writes negative test (§12) +- [x] `kt_render_roadmap`: reflects current DB state exactly, twice-with-no-changes byte-identical, explicit zero-writes negative test (§14) +- [x] Adapters: no-adapter-configured clean error (not crash) for both sync tools, credential-never-in-response swept across all 14 tools (§15, §16, §19) diff --git a/docs/TRD.md b/docs/TRD.md new file mode 100644 index 0000000..c16ca03 --- /dev/null +++ b/docs/TRD.md @@ -0,0 +1,1022 @@ +# KnoTrack — Technical Requirements Document + +| | | +|---|---| +| Document version | 1.0 | +| Date | 2026-08-23 | +| Status | Approved for implementation | +| Server version this TRD describes | `0.1.0` | +| MCP protocol version targeted | `2026-07-28` (stateless — no `initialize`/`initialized` handshake, no `Mcp-Session-Id`) | + +## 0. Scope + +KnoTrack is a self-hosted MCP server that gives coding agents (Claude Code/Cowork, Windsurf, Codex CLI, LM Studio, Goose, Hermes) durable, cross-session visibility into a project's tracks, work items, decisions, and drift — without acting as an orchestrator. It never assigns work, never blocks a client from doing anything, and never runs anything on a schedule. Every one of the 14 mandated MCP tools is a single self-contained, synchronous request/response call: no server-side session state is created or consulted between calls, because the target MCP spec revision (2026-07-28) is stateless. Two plain, unauthenticated HTTP routes — `GET /health` and `GET /info` — sit alongside the MCP endpoint for liveness/readiness and static server metadata respectively (§8); neither is an MCP tool call. Every call therefore repeats `project_id` (and `track_id`/`item_id` where relevant) explicitly; the server never infers "the current project" from a prior call. + +Deployment model: **self-hosted, single-tenant per instance.** One running KnoTrack process + one Postgres database serves one operator, who may register many projects and point many MCP clients (Claude Code, Windsurf, etc.) at the same instance. There is no multi-tenant control plane, no hosted SaaS, and no cross-instance data sharing. Isolating two teams means running two instances. + +--- + +## 1. Tech Stack + +| Layer | Choice | One-line justification | +|---|---|---| +| Runtime | Node.js 20+ | Current LTS at time of writing; native `fetch`, stable ESM, required baseline for `@modelcontextprotocol/sdk`. | +| Language | TypeScript, `strict: true` | Tool contracts are exact JSON shapes crossing a process boundary to arbitrary MCP clients — compile-time shape checking catches contract drift before it reaches a client. | +| MCP implementation | `@modelcontextprotocol/sdk` (official) | Only implementation guaranteed to track the MCP spec's wire format and transport details across revisions; hand-rolling JSON-RPC framing is pure risk with no upside. | +| Database | PostgreSQL (only supported DB) | One of three documented deploy targets is Render's free tier, which has **no attachable persistent local disk** — a SQLite/file-based DB would silently lose all data on every restart there. Postgres is available managed on all three targets (Supabase, Railway, Fly), so it's the only option that works identically everywhere. | +| DB driver | `pg` (node-postgres) | Minimal, direct SQL, no query-builder magic to fight when writing the recursive/graph queries drift detection and dependency validation need. | +| Migrations | `node-pg-migrate` | Produces plain, numbered, human-readable migration files that generate straight SQL; avoids pulling in a full ORM (Prisma/TypeORM) whose schema-modeling layer this project doesn't need and whose extra runtime/dependency weight self-hosted installers shouldn't have to carry. | +| HTTP framework | Fastify | Lightweight, first-class TypeScript types, low overhead, and its raw Node `req`/`res` are directly compatible with the MCP SDK's Streamable HTTP transport, which attaches to the raw HTTP layer rather than an Express-style middleware chain. | +| Testing | Vitest | Native ESM/TS support with no Babel/ts-jest transform step; fast enough to run the full suite (unit + integration against a real Postgres) on every commit. | +| Linting | ESLint + `typescript-eslint` | Type-aware lint rules catch a class of bugs (unsafe `any`, unchecked promise rejections) that matter a lot in code that talks to arbitrary untrusted MCP clients. | +| Formatting | Prettier | Removes formatting bikeshedding entirely; run in CI as a check, not a suggestion. | +| Encryption | Node built-in `node:crypto`, AES-256-GCM | See §5 — deliberately chosen over `pgcrypto` so the decryption key never has to live inside Postgres or touch a SQL statement. | +| Logging | Fastify's built-in Pino logger | Ships with Fastify, structured JSON logs by default, zero extra dependency. | +| Env/config validation | `zod` | Same library already used for tool input schemas (see §3); reused to parse and validate `process.env` at boot so a misconfigured deploy fails fast with a clear message instead of a confusing runtime error. | + +--- + +## 2. Repository Layout + +``` +knotrack/ +├── docs/ +│ └── TRD.md +├── src/ +│ ├── index.ts # process entrypoint: load config, run pre-flight checks, start Fastify +│ ├── config/ +│ │ └── env.ts # zod schema for process.env -> typed Config object +│ ├── server/ +│ │ ├── fastify.ts # builds the Fastify instance, registers routes/hooks +│ │ ├── mcp-route.ts # mounts POST /mcp using StreamableHTTPServerTransport (stateless mode) +│ │ ├── auth.ts # bearer-token preHandler hook (see §4) +│ │ └── health-route.ts # GET /health and GET /info, both plain unauthenticated routes (see §8) +│ ├── mcp/ +│ │ ├── server.ts # constructs the McpServer instance and registers the 14 canonical tools; /health and /info are mounted as plain Fastify routes, not MCP tools +│ │ ├── context.ts # per-call request context (db client, config) via AsyncLocalStorage +│ │ ├── errors.ts # KtError class + ERROR_CODES map (see §3.1) +│ │ └── tools/ +│ │ ├── register-project.ts +│ │ ├── get-project-status.ts +│ │ ├── list-tracks.ts +│ │ ├── get-track.ts +│ │ ├── get-next-steps.ts +│ │ ├── create-track.ts +│ │ ├── create-item.ts +│ │ ├── record-session-summary.ts +│ │ ├── record-decision.ts +│ │ ├── update-item-status.ts +│ │ ├── check-drift.ts +│ │ ├── render-roadmap.ts +│ │ ├── sync-to-github.ts +│ │ └── sync-to-linear.ts +│ ├── schemas/ +│ │ └── tools.ts # one zod schema per tool; single source of truth, converted to JSON Schema for tools/list +│ ├── db/ +│ │ ├── pool.ts # pg.Pool singleton, sized from KNOTRACK_DB_POOL_MAX +│ │ ├── migrations/ +│ │ │ ├── 1735689600000_init.js +│ │ │ ├── 1735689700000_add-sync-timestamps.js +│ │ │ └── ... # node-pg-migrate CommonJS migration files, timestamp-prefixed +│ │ └── queries/ +│ │ ├── projects.ts +│ │ ├── tracks.ts +│ │ ├── items.ts +│ │ ├── events.ts +│ │ ├── decisions.ts +│ │ ├── drift-flags.ts +│ │ └── adapter-credentials.ts +│ ├── domain/ +│ │ ├── dependency-graph.ts # topo sort + cycle detection, shared by create-track and create-item +│ │ ├── drift-detector.ts # the 6 drift flag rules (see Appendix C) +│ │ ├── next-steps.ts # recommendation ranking for kt_get_next_steps +│ │ └── roadmap-renderer.ts # markdown / mermaid rendering for kt_render_roadmap +│ ├── adapters/ +│ │ ├── types.ts # SyncAdapter interface +│ │ ├── github/ +│ │ │ ├── client.ts # thin wrapper over GitHub REST API using stored PAT +│ │ │ └── sync.ts +│ │ └── linear/ +│ │ ├── client.ts # thin wrapper over Linear GraphQL API using stored API key +│ │ └── sync.ts +│ └── crypto/ +│ └── credential-cipher.ts # AES-256-GCM encrypt/decrypt for adapter credentials (see §5) +├── tests/ +│ ├── unit/ # domain/ and crypto/ logic, no DB +│ ├── integration/ # full tool calls against a real Postgres (docker-compose or testcontainers) +│ └── fixtures/ +├── scripts/ +│ ├── migrate.ts # runs node-pg-migrate programmatically; invoked at deploy time, not in-process +│ └── generate-token.ts # prints a new candidate bearer token for KNOTRACK_API_TOKENS +├── .env.example +├── package.json +├── tsconfig.json +├── eslint.config.js # flat config (ESLint 9+) +├── .prettierrc.json +├── vitest.config.ts +├── Dockerfile +├── render.yaml # Render deploy config (build/start/health-check path) +├── railway.toml # Railway deploy config +├── fly.toml # Fly.io deploy config +└── README.md +``` + +--- + +## 3. Tool Contract Reference + +### 3.0 Conventions used below + +- All ids (`project_id`, `track_id`, `item_id`, `event_id`, `decision_id`, `flag_id`) are UUID v4 strings. Input schemas mark them `"format": "uuid"`; the actual runtime check is `zod`'s `.uuid()` (RFC 4122, version-agnostic — accepts any valid UUID, not only v4, since ids may originate from `gen_random_uuid()` which produces v4 but the validator does not need to be stricter than "is this a UUID"). +- Every input schema below is authored as a `zod` object in `src/schemas/tools.ts` and is the single source of truth. The `@modelcontextprotocol/sdk` converts it to the JSON Schema shown here for `tools/list` responses — the two are guaranteed identical because one is generated from the other, not hand-maintained twice. +- Every input schema is closed (`"additionalProperties": false`). Any field not listed is a `422 VALIDATION_ERROR`. +- Timestamps are ISO-8601 UTC strings with millisecond precision, e.g. `"2026-08-23T14:30:00.000Z"`. +- **Where a tool-level error is returned** (404/409/422/500), see §3.1 for exactly how it is packaged in the MCP response. **401 is never returned by a tool handler** — it is enforced entirely at the HTTP transport layer before any JSON-RPC/tool dispatch happens (see §4). It is listed per-tool below only to record that the tool is reachable at all solely through an authenticated request. +- Authorization model: any request bearing a currently-valid token (§4) has full read/write access to **every** project in this instance. There is no per-project or per-client ACL in v1 — isolating two teams' data means running two separate KnoTrack instances, consistent with the single-tenant-per-deployment architecture. + +### 3.1 Error envelope (used by every tool and by the HTTP layer) + +```json +{ + "error": { + "code": "NOT_FOUND", + "http_status_equivalent": 404, + "message": "project not found", + "details": { "project_id": "3f1a2b4c-9d3e-4a2f-8b21-6f0e2c9a1d55" } + } +} +``` + +`code` is one of exactly five string constants, each with a fixed `http_status_equivalent`: + +| `code` | `http_status_equivalent` | Meaning | +|---|---|---| +| `UNAUTHORIZED` | 401 | Missing, malformed, or unrecognized bearer token. | +| `NOT_FOUND` | 404 | A referenced `project_id` / `track_id` / `item_id` does not exist (or does not exist *within the given project*, which is treated identically to not existing at all — no cross-project existence is ever revealed). | +| `CONFLICT` | 409 | The request is well-formed and all referenced ids exist, but applying it would violate a state invariant (dependency cycle, marking an item done while its dependencies are unmet, syncing to an adapter with no credentials configured). | +| `VALIDATION_ERROR` | 422 | The request body failed JSON Schema validation, or passed schema validation but violates a business rule that isn't state-dependent (e.g. a `depends_on` item exists but belongs to a different track). | +| `INTERNAL_ERROR` | 500 | Anything unexpected: DB connection failure, decryption failure (corrupted ciphertext / wrong key), unhandled exception. Never includes stack traces or raw driver error text in `message`; those go to the server log only. | + +**Transport-level delivery of this envelope differs by error type, and this distinction is load-bearing for client implementers:** + +- **`UNAUTHORIZED` (401):** produced by the Fastify `preHandler` hook on `POST /mcp`, *before* the request body is parsed as JSON-RPC at all. The HTTP response is a genuine `401` status code with this envelope as the raw JSON body (`Content-Type: application/json`). It is not wrapped in any JSON-RPC or MCP tool-result structure. +- **`NOT_FOUND` / `CONFLICT` / `VALIDATION_ERROR` / `INTERNAL_ERROR`:** these occur *inside* a tool handler, after the JSON-RPC `tools/call` request has already been accepted. Per MCP convention, a tool-execution failure is reported as a **successful JSON-RPC response** whose result has `isError: true` and whose `content` is `[{ "type": "text", "text": "" }]`. This lets the calling agent see and reason about the error instead of the transport erroring opaquely. The HTTP status code for this response is `200`. +- Malformed JSON-RPC itself (bad method name, unparseable body) is handled by the SDK's own default JSON-RPC error responses (`-32600`/`-32601`/`-32700`) and is untouched by KnoTrack's envelope — this only concerns genuinely malformed protocol traffic, not tool-level business errors. +- **Known gap — `VALIDATION_ERROR` for a `tools/call` whose arguments fail `inputSchema` itself** (an unknown property, a malformed UUID, a missing required field): `@modelcontextprotocol/sdk` validates arguments against `inputSchema` *before* invoking KnoTrack's own tool handler, and formats that rejection itself — as an `isError: true` result (matching the bullet above), but with the SDK's own plain-text message as `content[0].text`, not `JSON.stringify` of this envelope. A client parsing that text expecting `{ "error": { "code": "VALIDATION_ERROR", ... } }` gets the SDK's raw message instead. See src/mcp/tool-helpers.ts's header comment for why this isn't fixed: it isn't interceptable per-tool, and the two ways to change it either break `tools/list`'s advertised schemas for all 14 tools or require forking the SDK's internal tool-dispatch handler. + +### 3.2 `kt_register_project` + +Registers a project, or **upserts** one: this is the only mechanism v1 provides for adding or rotating adapter credentials after initial registration (there is no separate "update credentials" tool in the mandated 14). Uniqueness is on `(source_type, source_ref)`. Calling this again with the same pair updates `name` and/or `adapters` on the existing row (re-encrypting any credentials supplied) and returns the **original** `project_id` unchanged — it never creates a duplicate and never returns `409` for "already exists". + +Input schema: +```json +{ + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "source_type": { "type": "string", "enum": ["github", "linear", "local"] }, + "source_ref": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "github: 'owner/repo'. linear: team key or team UUID. local: absolute or repo-relative filesystem path." + }, + "adapters": { + "type": "object", + "properties": { + "github": { + "type": "object", + "properties": { + "personal_access_token": { "type": "string", "minLength": 1 }, + "repo": { "type": "string", "minLength": 1, "description": "owner/repo; defaults to source_ref when source_type is 'github'" } + }, + "required": ["personal_access_token"], + "additionalProperties": false + }, + "linear": { + "type": "object", + "properties": { + "api_key": { "type": "string", "minLength": 1 }, + "team_id": { "type": "string", "minLength": 1 } + }, + "required": ["api_key", "team_id"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": ["name", "source_type", "source_ref"], + "additionalProperties": false +} +``` + +Example output: +```json +{ "project_id": "3f1a2b4c-9d3e-4a2f-8b21-6f0e2c9a1d55" } +``` + +Errors: `401`; `422` (empty `name`, invalid `source_type`, empty `source_ref`, `adapters.github` present without `personal_access_token`, `adapters.linear` present without both `api_key` and `team_id`, any unknown property); `500` (DB write failure, credential-encryption failure). No `404`, no `409` (see upsert semantics above). + +### 3.3 `kt_get_project_status` + +Input schema: +```json +{ + "type": "object", + "properties": { "project_id": { "type": "string", "format": "uuid" } }, + "required": ["project_id"], + "additionalProperties": false +} +``` + +Example output: +```json +{ + "tracks": [ + { + "track_id": "8b2e1a10-...", + "title": "Auth overhaul", + "status": "on_track", + "item_counts": { "pending": 2, "in_progress": 1, "done": 3, "blocked": 0 } + } + ], + "drift_flags": [ + { + "flag_id": "d1e5f0aa-...", + "flag_type": "STALE_TRACK", + "severity": "warning", + "track_id": "8b2e1a10-...", + "item_id": null, + "detail": "No session summary recorded for this track in 16 days.", + "status": "open", + "raised_at": "2026-08-20T10:00:00.000Z" + } + ], + "recent_events": [ + { + "event_id": "aa11bb22-...", + "event_type": "session_summary", + "track_id": "8b2e1a10-...", + "summary_text": "Wired up JWT refresh flow.", + "created_at": "2026-08-22T18:04:00.000Z" + } + ] +} +``` + +`drift_flags` returns only flags with `status = "open"`, newest first, capped at 100. `recent_events` unions `session_summary` events and `decision` events, ordered `created_at DESC`, capped at 20. + +Errors: `401`; `404` (`project_id` not found); `422` (malformed uuid); `500`. + +### 3.4 `kt_list_tracks` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "status": { "type": "string", "enum": ["on_track", "pivot_pending", "blocked", "done"] } + }, + "required": ["project_id"], + "additionalProperties": false +} +``` + +Example output: +```json +{ + "tracks": [ + { + "track_id": "8b2e1a10-...", + "title": "Auth overhaul", + "status": "on_track", + "source_doc_ref": "docs/auth-spec.md", + "depends_on_track_ids": [], + "item_counts": { "pending": 2, "in_progress": 1, "done": 3, "blocked": 0 }, + "created_at": "2026-08-01T12:00:00.000Z" + } + ] +} +``` + +When `status` is supplied, filtering is a direct `WHERE tracks.status = ...` clause, since track status is a stored column (§3.5) — no post-processing step is needed. + +Errors: `401`; `404` (project not found); `422` (bad `status` enum value, malformed uuid); `500`. + +### 3.5 `kt_get_track` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" } + }, + "required": ["project_id", "track_id"], + "additionalProperties": false +} +``` + +Example output: +```json +{ + "track": { + "track_id": "8b2e1a10-...", + "title": "Auth overhaul", + "status": "on_track", + "source_doc_ref": "docs/auth-spec.md", + "depends_on_track_ids": [], + "created_at": "2026-08-01T12:00:00.000Z" + }, + "items": [ + { "item_id": "0a1b2c3d-...", "title": "Add refresh endpoint", "status": "done", "sequence_position": 1, "depends_on_item_ids": [] }, + { "item_id": "1b2c3d4e-...", "title": "Add rotation tests", "status": "pending", "sequence_position": 2, "depends_on_item_ids": ["0a1b2c3d-..."] } + ], + "dependency_graph": { + "nodes": [ + { "item_id": "0a1b2c3d-...", "title": "Add refresh endpoint", "status": "done" }, + { "item_id": "1b2c3d4e-...", "title": "Add rotation tests", "status": "pending" } + ], + "edges": [ + { "item_id": "1b2c3d4e-...", "depends_on_item_id": "0a1b2c3d-..." } + ] + } +} +``` + +`dependency_graph.edges` uses explicit field names (`item_id`, `depends_on_item_id`) rather than generic `from`/`to` specifically to remove any ambiguity about edge direction: the edge `{item_id: A, depends_on_item_id: B}` means "A depends on B; A cannot be marked done until B is done." + +**Track status is a stored column (`tracks.status`), not derived.** It defaults to `on_track` and only ever changes via two write paths, both covered elsewhere in this document: + +- `kt_create_track` (§3.6) sets the initial value at insert time: `on_track`, or `blocked` if any listed `depends_on` track is not yet `done`. +- `kt_record_decision` (§3.10) sets the referenced track's status to `pivot_pending`, in the same transaction as inserting the decision row. + +No other tool writes `tracks.status` — there is no `kt_update_track` tool and no read-time derivation step. A read (this tool, `kt_list_tracks`, `kt_get_project_status`) simply selects the stored value. + +Errors: `401`; `404` (project or track not found); `422` (malformed uuid); `500`. + +### 3.6 `kt_create_track` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "title": { "type": "string", "minLength": 1, "maxLength": 300 }, + "depends_on": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "maxItems": 50, + "default": [] + }, + "source_doc_ref": { "type": "string", "maxLength": 500 } + }, + "required": ["project_id", "title"], + "additionalProperties": false +} +``` + +Example output: +```json +{ "track_id": "9c3d4e5f-..." } +``` + +**Initial `status` (stored, see §3.5):** the new row's `tracks.status` is set at insert time to `on_track`, unless at least one track listed in `depends_on` does not yet have `status = 'done'`, in which case it is set to `blocked` instead. This is one of exactly two write paths that ever set `tracks.status` — the other is `kt_record_decision` (§3.10), which moves a track to `pivot_pending`. + +**Cycle check (systemic invariant):** before insert, the server runs a full topological-sort validation of the project's track-dependency graph *including the proposed new edges* (`src/domain/dependency-graph.ts`, shared with `kt_create_item`). Duplicate ids inside `depends_on` are silently de-duplicated, not an error. Note that with the mandated v1 tool set there is in fact no operation that can introduce an edge pointing *back* to a freshly created node (there is no "add dependency to an existing track" tool), so a true cycle cannot occur through track creation alone today — the check is implemented anyway as a systemic invariant enforced identically at both `kt_create_track` and `kt_create_item`, so the server fails safe the moment any future tool (e.g. a hypothetical `kt_add_track_dependency`) is added, rather than only being caught then. + +Errors: `401`; `404` (project not found, or any `depends_on` id does not correspond to an existing track in this project); `409` (dependency cycle detected); `422` (empty `title`, malformed uuid, `depends_on` not an array); `500`. + +### 3.7 `kt_create_item` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" }, + "title": { "type": "string", "minLength": 1, "maxLength": 300 }, + "sequence_position": { "type": "integer", "minimum": 0 }, + "depends_on": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "maxItems": 100, + "default": [] + } + }, + "required": ["project_id", "track_id", "title"], + "additionalProperties": false +} +``` + +**Scope restriction (v1):** every id in `depends_on` must belong to the **same** `track_id` as the item being created. Cross-track item dependencies are out of scope for v1 — use a track-level `depends_on` (§3.6) to order work across tracks instead. This keeps the `dependency_graph` returned by `kt_get_track` a single self-contained per-track DAG with no need to reach into other tracks. + +If `sequence_position` is omitted, the server assigns `MAX(sequence_position) + 1` within the track (or `1` if the track has no items yet). + +Example output: +```json +{ "item_id": "1b2c3d4e-..." } +``` + +Errors: `401`; `404` (project or track not found, or a `depends_on` id does not exist as an item at all); `409` (dependency cycle detected — same systemic check as §3.6, evaluated over the track's item graph); `422` (empty `title`, negative `sequence_position`, a `depends_on` id exists but belongs to a **different** track — a business-rule violation, not a not-found); `500`. + +### 3.8 `kt_get_next_steps` + +Advertised via MCP tool annotations as `readOnlyHint: true, idempotentHint: true`. **Advisory only — this tool never assigns, claims, or locks an item; it only ranks candidates for a human or agent to choose from.** + +Input schema: +```json +{ + "type": "object", + "properties": { "project_id": { "type": "string", "format": "uuid" } }, + "required": ["project_id"], + "additionalProperties": false +} +``` + +Algorithm (`src/domain/next-steps.ts`), fully deterministic: +1. Select every item with `status = 'pending'`. +2. Keep only items where every `depends_on_item_id` has `status = 'done'` (or the item has no dependencies). +3. Drop items whose track has stored `status = 'blocked'` (`tracks.status`, §3.5 — a plain `WHERE`, no computation needed) — a track-level block always wins over an individually-ready item. +4. Order the survivors by: track status priority (`on_track` before `pivot_pending`, since a track under active reconsideration is deprioritized until the pivot is resolved), then `sequence_position` ascending, then `created_at` ascending. +5. Take the top `KNOTRACK_NEXT_STEPS_LIMIT` (default 5, §7). +6. `reason` is generated from a fixed template: + - No dependencies: `"No dependencies — ready to start in track \"{track_title}\"."` + - Has dependencies: `"All {n} dependencies complete — next up in track \"{track_title}\"."` + +Example output: +```json +{ + "recommended_items": [ + { + "item_id": "1b2c3d4e-...", + "title": "Add rotation tests", + "track_id": "8b2e1a10-...", + "track_title": "Auth overhaul", + "reason": "All 1 dependency complete — next up in track \"Auth overhaul\"." + } + ] +} +``` +(The mandated minimum shape is `{item_id, reason}`; `title`, `track_id`, `track_title` are additional fields included for client convenience — output is not schema-validated as strictly as input, so additive fields are safe.) + +Errors: `401`; `404` (project not found); `422` (malformed uuid); `500`. + +### 3.9 `kt_record_session_summary` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" }, + "summary_text": { "type": "string", "minLength": 1, "maxLength": 10000 }, + "files_touched": { + "type": "array", + "items": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "default": [] + }, + "items_touched": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "default": [] + } + }, + "required": ["project_id", "track_id", "summary_text"], + "additionalProperties": false +} +``` + +On success the server inserts the event, then re-runs the drift-detector rules **scoped to this one track only** (not the whole project) and returns whichever flags that scoped pass newly opened. This event is also what resets the `STALE_TRACK` staleness clock (see Appendix C) — a bare `kt_update_item_status` call does **not** reset it, so the signal can't be gamed by toggling a status without ever describing what happened. + +Example output: +```json +{ + "event_id": "aa11bb22-...", + "drift_flags_raised": [ + { + "flag_id": "e2f3a4b5-...", + "flag_type": "SEQUENCE_SKIP", + "severity": "info", + "detail": "Item 'Add rotation tests' (seq 2) is done while an earlier item 'Add refresh endpoint' (seq 1) is still pending." + } + ] +} +``` + +Errors: `401`; `404` (project or track not found, or an `items_touched` id does not exist as an item at all); `422` (empty `summary_text`, a `files_touched` entry is not a string, an `items_touched` id exists but belongs to a **different** track than `track_id`); `500`. + +### 3.10 `kt_record_decision` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" }, + "title": { "type": "string", "minLength": 1, "maxLength": 300 }, + "rationale": { "type": "string", "minLength": 1, "maxLength": 5000 }, + "what_changed": { "type": "string", "minLength": 1, "maxLength": 5000 } + }, + "required": ["project_id", "track_id", "title", "rationale", "what_changed"], + "additionalProperties": false +} +``` + +Example output: +```json +{ "decision_id": "c4d5e6f7-..." } +``` + +**Side effect on `tracks.status` (stored, see §3.5):** in the same transaction as the `decisions` insert, the server sets `track_id`'s `tracks.status` to `pivot_pending` — recording a decision is, by definition, the track pivoting on something, and this is one of exactly two write paths for `tracks.status` (the other being `kt_create_track`, §3.6, at creation time). + +Errors: `401`; `404` (project or track not found); `422` (`title`, `rationale`, or `what_changed` empty); `500`. + +### 3.11 `kt_update_item_status` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "item_id": { "type": "string", "format": "uuid" }, + "status": { "type": "string", "enum": ["pending", "in_progress", "done", "blocked"] } + }, + "required": ["project_id", "item_id", "status"], + "additionalProperties": false +} +``` + +**Business rule:** transitioning to `"done"` requires every `depends_on_item_id` of this item to already be `"done"`. Any other transition (`pending`, `in_progress`, `blocked`, or `done → done`/no-op) is unconstrained. + +Example output: +```json +{ "ok": true } +``` + +Example `409` response body (inside the `isError` envelope, per §3.1): +```json +{ + "error": { + "code": "CONFLICT", + "http_status_equivalent": 409, + "message": "cannot mark item done: 1 unmet dependency", + "details": { "item_id": "1b2c3d4e-...", "unmet_item_ids": ["0a1b2c3d-..."] } + } +} +``` + +Errors: `401`; `404` (project or item not found); `409` (transition to `done` with unmet dependencies); `422` (invalid `status` value, malformed uuid); `500`. + +### 3.12 `kt_check_drift` + +Full, project-wide, synchronous drift scan. See §6 for the time/size budget and truncation behavior, and Appendix C for the six drift-flag types this evaluates. + +Input schema: +```json +{ + "type": "object", + "properties": { "project_id": { "type": "string", "format": "uuid" } }, + "required": ["project_id"], + "additionalProperties": false +} +``` + +Example output: +```json +{ + "flags": [ + { + "flag_id": "d1e5f0aa-...", + "flag_type": "STALE_TRACK", + "severity": "warning", + "track_id": "8b2e1a10-...", + "item_id": null, + "detail": "No session summary recorded for this track in 16 days.", + "status": "open", + "raised_at": "2026-08-20T10:00:00.000Z" + } + ], + "truncated": false, + "scanned_track_count": 12, + "total_track_count": 12, + "scan_duration_ms": 184 +} +``` + +Errors: `401`; `404` (project not found); `422` (malformed uuid); `500`. Note: exceeding the time/size budget is **not** an error — it degrades to a `truncated: true` result (§6), by design, so a large project never turns a routine drift check into a hard failure. + +### 3.13 `kt_render_roadmap` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "format": { "type": "string", "enum": ["markdown", "mermaid"], "default": "markdown" } + }, + "required": ["project_id"], + "additionalProperties": false +} +``` + +**`markdown` format** (default) — one `##` heading per track in topological (dependency) order, then a checklist of its items in `sequence_position` order: +``` +# Roadmap: KnoTrack Demo +_Generated 2026-08-23T14:30:00.000Z_ + +## Auth overhaul — on_track +- [x] Add refresh endpoint +- [ ] Add rotation tests + +## Billing sync — blocked +- [ ] Define webhook contract +``` +Item checkbox rendering: `[x]` for `done`, `[ ]` for `pending`, `[~]` for `in_progress`, `[!]` for `blocked`. + +**`mermaid` format** — a `graph TD` of track-level dependencies, one node per track labeled `"{title} ({status})"` (double quotes inside a title are replaced with single quotes and newlines stripped, to keep the diagram syntactically valid): +``` +graph TD + t_8b2e1a10["Auth overhaul (on_track)"] + t_9c3d4e5f["Billing sync (blocked)"] + t_9c3d4e5f --> t_8b2e1a10 +``` +(Edge `A --> B` means "A depends on B", matching the `depends_on_track_ids` direction used everywhere else in this document. Here, Billing sync depends on Auth overhaul, which is not yet `done` — consistent with `kt_create_track`'s rule (§3.6) for setting a new track's initial `status` to `blocked`.) + +Example output: +```json +{ "content": "# Roadmap: KnoTrack Demo\n_Generated 2026-08-23T14:30:00.000Z_\n\n## Auth overhaul — on_track\n- [x] Add refresh endpoint\n- [ ] Add rotation tests\n" } +``` + +Degrades gracefully on a large project — see §6 for the exact caps and the truncation-notice text appended to `content`. + +Errors: `401`; `404` (project not found); `422` (invalid `format` value); `500`. + +### 3.14 `kt_sync_to_github` + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" } + }, + "required": ["project_id", "track_id"], + "additionalProperties": false +} +``` + +Two distinct failure surfaces, deliberately kept separate: +- **Preconditions the caller can fix by calling a different tool first** (no `github` credentials stored for this project) → a real tool-level error, `409 CONFLICT`, via the `isError` envelope (§3.1). +- **Everything about talking to GitHub itself** (bad token, repo not found, rate-limited, network timeout) → **not** an MCP-level error at all; the tool call succeeds and returns the discriminated result `{ok: false, error: "..."}` per the mandated signature, because these are expected, retryable operational outcomes rather than contract violations. + +Example success output: +```json +{ "ok": true } +``` + +Example operational-failure output (still a successful tool call): +```json +{ "ok": false, "error": "GITHUB_RATE_LIMITED: retry after 120s" } +``` +Other `error` string prefixes used: `GITHUB_AUTH_FAILED` (401/403 from GitHub — token revoked or insufficient scope), `GITHUB_NOT_FOUND` (repo or issue not found), `GITHUB_TIMEOUT` (exceeded `KNOTRACK_GITHUB_SYNC_TIMEOUT_MS`, default 8000ms), `GITHUB_UNKNOWN_ERROR` (anything else, with the upstream status code appended). + +Errors (tool-level, via `isError`): `401`; `404` (project or track not found); `409` (no GitHub credentials configured for this project — i.e. no row in `adapter_credentials` for `(project_id, 'github')`); `422` (malformed uuid); `500` (credential decryption failure, unexpected local exception before the GitHub call was even attempted). + +### 3.15 `kt_sync_to_linear` + +Identical shape and semantics to `kt_sync_to_github`, mirrored for Linear. + +Input schema: +```json +{ + "type": "object", + "properties": { + "project_id": { "type": "string", "format": "uuid" }, + "track_id": { "type": "string", "format": "uuid" } + }, + "required": ["project_id", "track_id"], + "additionalProperties": false +} +``` + +Example success output: +```json +{ "ok": true } +``` +Example operational-failure output: +```json +{ "ok": false, "error": "LINEAR_AUTH_FAILED: API key rejected" } +``` +`error` string prefixes: `LINEAR_AUTH_FAILED`, `LINEAR_NOT_FOUND` (team or issue not found), `LINEAR_TIMEOUT` (exceeded `KNOTRACK_LINEAR_SYNC_TIMEOUT_MS`, default 8000ms), `LINEAR_UNKNOWN_ERROR`. + +Errors (tool-level, via `isError`): `401`; `404` (project or track not found); `409` (no Linear credentials configured for this project); `422` (malformed uuid); `500`. + +--- + +## 4. Auth Mechanics + +**Model:** a single shared-secret pool of bearer tokens per instance, configured entirely via environment variable — no database table, no issuance flow, no per-client identity. This matches the deployment model directly: one operator, a handful of MCP clients on machines they control, all trusted equally. + +- **Token source:** `KNOTRACK_API_TOKENS`, a comma-separated list of one or more opaque strings (required at boot; the server refuses to start if it is unset or empty — see §7 — so it is never accidentally reachable with no auth at all). +- **Token format (convention, not enforced):** `kt_` followed by 43 URL-safe base64 characters (32 random bytes / 256 bits of entropy), e.g. `kt_5WbJxZlXdRqTUfSiHhONoLK83WXrB78i4NJYjzw9WmE`. Produced by `npm run generate-token`, which prints one new candidate to stdout (never written anywhere). The server does not validate this shape at auth time — it only requires the presented token to exactly match one entry in `KNOTRACK_API_TOKENS`; this keeps the format a convention for humans, not a parser constraint. +- **Where it's checked:** a Fastify `preHandler` hook registered on `POST /mcp` only (never on `GET /health` or `GET /info`, both of which must stay reachable without credentials — see §8). For every request: read the `Authorization` header, require it to be exactly `Bearer ` (case-sensitive scheme, single space), then compare `` against each entry in `KNOTRACK_API_TOKENS`. +- **Comparison method:** to avoid timing side-channels across the whole array, the presented token and every configured token are first hashed with SHA-256, then compared pairwise with `crypto.timingSafeEqual` on the fixed-length 32-byte digests (this also sidesteps `timingSafeEqual`'s requirement that both buffers be equal length, since raw token lengths could otherwise differ and leak length information). A match against **any** entry authorizes the request. +- **On failure** (missing header, wrong scheme, no match): respond `401` with the standard error envelope (§3.1), `message: "missing or invalid bearer token"`. The response never distinguishes "header missing" from "token present but not recognized" — both look identical externally, to avoid giving an attacker a probing oracle. +- **Rotation approach (manual, by design — no in-band rotation API in v1):** + 1. Generate a new token with `npm run generate-token`. + 2. Add it to `KNOTRACK_API_TOKENS` (append, comma-separated) and redeploy. Both old and new tokens are now valid simultaneously — this is what makes the rotation zero-downtime. + 3. Update each MCP client's configuration (Claude Code `mcp.json` `headers`, Windsurf's MCP config, etc.) to the new token, one at a time. + 4. Once every client is confirmed updated, remove the old token from `KNOTRACK_API_TOKENS` and redeploy again. + - There is no automatic expiry in v1. Operators are advised (README) to rotate on a schedule they choose (e.g. every 90 days) or immediately if a token is suspected leaked (in which case skip straight to removing it in step 4, accepting the resulting downtime for clients not yet updated). + +--- + +## 5. Adapter Credential Encryption + +**Decision: application-level AES-256-GCM via Node's built-in `node:crypto`, key from `KNOTRACK_ENCRYPTION_KEY`. Not `pgcrypto`.** + +**Rationale:** `pgcrypto`'s `pgp_sym_encrypt`/`pgp_sym_decrypt` require the decryption passphrase to be passed as a SQL argument on every call, which means the key transits the Postgres connection and can end up in `pg_stat_statements`, slow-query logs, or a DBA's SQL console (e.g. Supabase's web-based SQL editor) — exactly the kind of incidental exposure a secrets-at-rest design is supposed to prevent. It also requires the `pgcrypto` extension to be enabled; Supabase ships it by default, but Railway's and Fly.io's plain-Postgres images do not, which would turn "which extension is enabled" into a per-deploy-target gotcha this project explicitly wants to avoid (all three targets must work against the identical schema and codebase). Keeping the key purely in application process memory (read once from an env var at boot, never persisted, never sent to Postgres) means the database only ever stores opaque ciphertext, and a full `pg_dump` of a compromised database reveals nothing without the separately-held key. + +**Concrete implementation** (`src/crypto/credential-cipher.ts`): + +- **Key:** exactly 32 raw bytes, provided as a base64 string in `KNOTRACK_ENCRYPTION_KEY`. Generate with `openssl rand -base64 32`. Decoded once at boot; the server refuses to start if the decoded length is not exactly 32 bytes. +- **Per-secret encryption:** + 1. Generate a fresh random 12-byte IV: `crypto.randomBytes(12)` (12 bytes / 96 bits is the AES-GCM-recommended nonce size). + 2. `const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)` + 3. `const ciphertext = Buffer.concat([cipher.update(plaintextUtf8, 'utf8'), cipher.final()])` + 4. `const authTag = cipher.getAuthTag()` (16 bytes) + 5. Persist all three (`ciphertext`, `iv`, `authTag`) plus a `key_version` integer (see rotation, below). +- **Decryption:** + 1. `const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)` + 2. `decipher.setAuthTag(authTag)` + 3. `const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')` + 4. If `decipher.final()` throws (auth tag mismatch — tampered or corrupted ciphertext, or wrong key), the error is caught, logged server-side with no secret material in the log line, and surfaced to the caller as a generic `500 INTERNAL_ERROR` (never leaking which part of the crypto operation failed). +- **Storage:** table `adapter_credentials` — `project_id`, `adapter_type` (`'github'` | `'linear'`), `ciphertext bytea`, `iv bytea`, `auth_tag bytea`, `key_version int default 1`, unique on `(project_id, adapter_type)`. See Appendix A for full DDL. +- **Never returned:** the `projects.adapters` JSONB column stores **only non-secret metadata** — e.g. `{"github": {"repo": "acme/widgets", "connected": true}}` — and is what every read-path tool (`kt_get_project_status`, `kt_list_tracks`, `kt_get_track`) serializes. The `adapter_credentials` table is read **only** by `src/adapters/github/client.ts` and `src/adapters/linear/client.ts` immediately before making an outbound API call in `kt_sync_to_github`/`kt_sync_to_linear`, and its columns never appear in any tool's output type — there is no code path that could accidentally include them. +- **Known gap — no key rotation path shipped in v1:** the `key_version` column exists specifically to support rotation later, but no rotation script or command ships yet — there is no `scripts/rotate-encryption-key.ts` and no corresponding `package.json` script. If `KNOTRACK_ENCRYPTION_KEY` is compromised today, the only recourse is manual: write and run a one-off script that loads every `adapter_credentials` row, decrypts with the old key, re-encrypts with a new key and fresh IV, writes back, and bumps `key_version`, then update `KNOTRACK_ENCRYPTION_KEY` and redeploy — the same shape a shipped `rotate-encryption-key` command would follow, just not packaged as one. This is tracked as follow-up work, to be built when key rotation is actually needed rather than speculatively now. + +--- + +## 6. Non-Functional Technical Targets + +### 6.1 Response time budgets (p95, measured server-side from request-received to response-sent, excluding network) + +| Tool class | Tools | Budget | +|---|---|---| +| Simple reads | `kt_get_project_status`, `kt_list_tracks`, `kt_get_track`, `kt_get_next_steps` | **< 200ms** — one to a handful of indexed queries. | +| Plain HTTP routes | `GET /health`, `GET /info` | **< 200ms** — not MCP tools; see §8 for both specs. | +| Writes | `kt_register_project`, `kt_create_track`, `kt_create_item`, `kt_record_session_summary`, `kt_record_decision`, `kt_update_item_status` | **< 300ms** — includes the dependency/cycle-check query on top of the write. | +| Full drift scan | `kt_check_drift` | **< 2000ms** typical; **hard-capped at `KNOTRACK_DRIFT_SCAN_TIMEOUT_MS`** (default 5000ms), past which it returns a `truncated: true` partial result rather than erroring (§6.3). | +| Roadmap render | `kt_render_roadmap` | **< 1500ms** for projects up to 50 tracks / 500 items total; beyond that, degrades per §6.3 rather than slowing further. | +| External sync | `kt_sync_to_github`, `kt_sync_to_linear` | **< 3000ms** typical, bounded by the external API; a hard **8000ms** timeout (`KNOTRACK_GITHUB_SYNC_TIMEOUT_MS` / `KNOTRACK_LINEAR_SYNC_TIMEOUT_MS`) against the outbound call, past which the tool returns `{ok: false, error: "GITHUB_TIMEOUT"}` / `{ok: false, error: "LINEAR_TIMEOUT"}`. | + +### 6.2 Postgres connections + +- `pg.Pool` is a **single process-wide singleton** (`src/db/pool.ts`), `max` connections controlled by `KNOTRACK_DB_POOL_MAX` (default **10**). +- Rationale for a low default: this is a self-hosted, single-tenant server expected to serve a handful of MCP clients on one operator's machines, not a multi-tenant fleet — 10 concurrent connections is generous headroom for that load while staying well under every free/small-tier Postgres connection cap across the three deploy targets (Supabase free tier's default pooled limit, Railway's small Postgres plan, Fly's smallest Postgres allocation), leaving room for the platform's own management connections. +- Documented ceiling: operators may raise `KNOTRACK_DB_POOL_MAX`, but the README recommends staying at or below **20** for exactly that reason. +- `idleTimeoutMillis: 30000`, `connectionTimeoutMillis: 5000` on the pool — a connection that can't be acquired in 5s surfaces as a `500 INTERNAL_ERROR`, not an indefinite hang. + +### 6.3 Graceful degradation on a large project + +Both `kt_check_drift` and `kt_render_roadmap` are explicitly flagged in this document as **candidates for the MCP Tasks extension** (asynchronous, resumable long-running operations) in a future version — but Tasks is not part of the 2026-07-28 spec baseline this server targets, so v1 keeps both **synchronous, capped, and time-boxed** within a single request instead of ever spawning background work the stateless protocol has no way to let a client poll for. + +**`kt_check_drift`:** +- Scans at most `KNOTRACK_DRIFT_SCAN_TRACK_CAP` tracks (default **500**) and `KNOTRACK_DRIFT_SCAN_ITEM_CAP` items (default **5000**) per invocation, oldest-track-first (by `created_at`) if the project exceeds the cap. +- Wrapped in a wall-clock budget of `KNOTRACK_DRIFT_SCAN_TIMEOUT_MS` (default **5000ms**) via `Promise.race` against a timer; if the timer wins, whatever flags were computed for the tracks processed so far are returned immediately. +- Either limit being hit sets `"truncated": true` in the response, alongside `scanned_track_count` and `total_track_count` so the caller can see exactly how partial the result is. This is a normal, non-error response (§3.12) — a large project degrades to "less thorough" rather than "broken." + +**`kt_render_roadmap`:** +- Renders at most `KNOTRACK_ROADMAP_TRACK_CAP` tracks (default **200**) and, per track, at most `KNOTRACK_ROADMAP_ITEM_PER_TRACK_CAP` items (default **100**), in the same topological/sequence order used elsewhere. +- Same 5000ms wall-clock budget as drift scanning; if hit mid-render, the partial content generated so far is returned rather than the request timing out. +- Because the tool's only output field is `content` (a single string), truncation is communicated **inline**, appended as the final line(s) of that string, e.g.: + ``` + > Roadmap truncated: showing 200 of 341 tracks. Some tracks omit items beyond the first 100. + ``` + +--- + +## 7. Environment Variables + +| Name | Required? | Default | Purpose | +|---|---|---|---| +| `DATABASE_URL` | **Required** | — | Postgres connection string (`postgres://user:pass@host:port/db`). Supabase, Railway, and Fly's managed Postgres add-ons each inject this automatically when attached. | +| `KNOTRACK_API_TOKENS` | **Required** | — | Comma-separated list of one or more accepted bearer tokens (§4). Server refuses to boot if unset or empty — deliberately, so an instance can never be accidentally reachable with no auth. | +| `KNOTRACK_ENCRYPTION_KEY` | **Required** | — | Base64-encoded 32-byte key for AES-256-GCM adapter-credential encryption (§5). Server refuses to boot if unset, malformed base64, or not exactly 32 decoded bytes. | +| `NODE_ENV` | Optional | `production` | Standard Node environment flag; controls default logging verbosity and `DATABASE_SSL_MODE`'s own default. | +| `PORT` | Optional | `8080` | HTTP listen port. **Must** be read from the environment first — Render, Railway, and Fly.io all inject their own `PORT` value and route external traffic to it; a hardcoded port breaks all three. | +| `HOST` | Optional | `0.0.0.0` | HTTP bind address. Must be `0.0.0.0` (not `localhost`/`127.0.0.1`) for the process to be reachable inside any of the three platforms' containers. | +| `DATABASE_SSL_MODE` | Optional | `require` in production, `disable` otherwise | Controls whether the `pg.Pool` is configured with TLS at all (`ssl: { rejectUnauthorized: }` when `require`, no `ssl` option when `disable`). Supabase and most managed Postgres require TLS on their public connection string. **Fly.io quirk:** when connecting to a Fly Postgres app over its private `6PN` internal network (the normal, recommended path), the server does not present a TLS certificate — set this to `disable` for that configuration, or `require` if connecting over Fly's public proxy instead. | +| `KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED` | Optional | `true` | Only consulted when `DATABASE_SSL_MODE=require`. Verifies the Postgres server's TLS certificate against trusted CAs by default. Set to `false` only for a broken/self-signed local dev certificate — never in production, since disabling it keeps the channel encrypted but accepts any certificate (vulnerable to MITM). | +| `KNOTRACK_DB_POOL_MAX` | Optional | `10` | Max `pg.Pool` connections (§6.2). Recommended ceiling **20**. | +| `KNOTRACK_DRIFT_SCAN_TRACK_CAP` | Optional | `500` | Max tracks scanned per `kt_check_drift` call (§6.3). | +| `KNOTRACK_DRIFT_SCAN_ITEM_CAP` | Optional | `5000` | Max items scanned per `kt_check_drift` call (§6.3). | +| `KNOTRACK_DRIFT_SCAN_TIMEOUT_MS` | Optional | `5000` | Wall-clock budget for `kt_check_drift` before returning a truncated result (§6.3). | +| `KNOTRACK_ROADMAP_TRACK_CAP` | Optional | `200` | Max tracks rendered per `kt_render_roadmap` call (§6.3). | +| `KNOTRACK_ROADMAP_ITEM_PER_TRACK_CAP` | Optional | `100` | Max items rendered per track per `kt_render_roadmap` call (§6.3). | +| `KNOTRACK_STALE_TRACK_DAYS` | Optional | `14` | Days of no `kt_record_session_summary` event on an `on_track` track before `STALE_TRACK` fires (Appendix C). | +| `KNOTRACK_NEXT_STEPS_LIMIT` | Optional | `5` | Max items returned by `kt_get_next_steps` (§3.8). | +| `KNOTRACK_GITHUB_SYNC_TIMEOUT_MS` | Optional | `8000` | Hard timeout on the outbound GitHub API call inside `kt_sync_to_github` (§6.1). | +| `KNOTRACK_LINEAR_SYNC_TIMEOUT_MS` | Optional | `8000` | Hard timeout on the outbound Linear API call inside `kt_sync_to_linear` (§6.1). | +| `LOG_LEVEL` | Optional | `info` | Fastify/Pino log level (`fatal`\|`error`\|`warn`\|`info`\|`debug`\|`trace`). | + +**Deploy-target quirks summary** (all three run the identical codebase/schema — no target-specific branches in application code, only environment-variable configuration): + +- **Render (free tier):** no persistent local disk — this is *the* reason Postgres-only is mandated (§1). Requires `GET /health` (§8) configured as Render's health-check path, since free-tier instances spin down on idle and Render polls this path to detect when the cold-started instance is ready again; the first request after a cold start may exceed the §6.1 budgets and this is an accepted, documented exception rather than a bug. Migrations run via Render's "pre-deploy command" (`npm run migrate`), not in the app's request path. +- **Railway:** `DATABASE_URL` is auto-injected into the app's environment when a Postgres plugin is attached in the same project — no manual wiring needed. Migrations run via a `startCommand` wrapper: `npm run migrate && npm start`. +- **Fly.io:** `DATABASE_URL` comes from `fly postgres attach`. See the `DATABASE_SSL_MODE` row above for the private-networking TLS quirk. Migrations run the same `npm run migrate && npm start` wrapper via `fly.toml`'s `release_command` (preferred, runs once per deploy before the new version takes traffic) rather than the `startCommand`. + +--- + +## 8. Health / Readiness / Info Endpoints + +**`GET /health`** — combines liveness and readiness into a single endpoint by design, since all three platforms' free/small-tier health checkers hit exactly one configured URL with no custom headers and no concept of "check two things." **Unauthenticated** — it must never require the `Authorization` header, or platform health checkers (which never send one) would report the instance permanently unhealthy. + +**Behavior:** +1. The Fastify process being able to answer at all is baseline liveness. +2. The handler additionally runs `SELECT 1` against the connection pool with a **1000ms** timeout, to confirm actual DB reachability (not just process liveness) — a KnoTrack instance whose Postgres is unreachable is not meaningfully "healthy" even though the HTTP server itself is up. +3. Total handler time budget: **under 2000ms**, comfortably inside the ~2–5s default health-check timeouts these platforms use; the 1000ms DB-query timeout leaves headroom for the rest of the handler. + +**Success (DB reachable) — `200 OK`:** +```json +{ + "status": "ok", + "version": "0.1.0", + "mcp_protocol_version": "2026-07-28", + "uptime_seconds": 4213, + "db": "ok" +} +``` + +**Failure (DB unreachable or the `SELECT 1` timed out) — `503 Service Unavailable`:** +```json +{ + "status": "error", + "version": "0.1.0", + "uptime_seconds": 4213, + "db": "error", + "error": "db_unreachable" +} +``` + +**Cold-start / migration ordering (critical for Render and Railway):** migrations are run as a **separate deploy-time step** (`npm run migrate`, via Render's pre-deploy command / Railway's `startCommand` wrapper / Fly's `release_command` — see §7) and must complete **before** the application process starts listening on `PORT`. `/health` never runs migrations and never blocks waiting for them — it assumes the schema is already current by the time the process is accepting connections at all, so a platform's repeated health-check polling during a slow migration can never race a half-migrated schema. + +**`GET /info`** — static server metadata. A **plain, unauthenticated HTTP route**, not an MCP tool: the target 2026-07-28 MCP spec has no `initialize` handshake to carry `serverInfo`/capability negotiation, but this information is operationally useful to fetch without an authenticated MCP round-trip (e.g. a deploy-verification script, or a client deciding which adapters it can rely on before it ever presents a bearer token), so it is mounted alongside `/health` rather than exposed as one of the 14 MCP tools. + +Behavior: no arguments, no auth, no DB access — always `200 OK`, computed entirely from in-process state. + +**Response — `200 OK`:** +```json +{ + "server_version": "0.1.0", + "mcp_protocol_version": "2026-07-28", + "node_version": "v20.17.0", + "supported_adapters": ["github", "linear"], + "instance_started_at": "2026-08-23T09:00:00.000Z" +} +``` + +--- + +## Appendix A — PostgreSQL Schema (DDL) + +Expressed here as the target schema; in the repository this is built up incrementally across `node-pg-migrate` files under `src/db/migrations/` (plain CommonJS files using the `pgm` builder API — e.g. `pgm.createTable(...)`, `pgm.addConstraint(...)` — which each generate and print the exact SQL they run, keeping migrations both diffable and human-readable without hand-writing raw SQL strings). + +```sql +create extension if not exists pgcrypto; -- only for gen_random_uuid(); credentials themselves never use pgcrypto (see §5) + +create table projects ( + id uuid primary key default gen_random_uuid(), + name text not null, + source_type text not null check (source_type in ('github', 'linear', 'local')), + source_ref text not null, + adapters jsonb not null default '{}'::jsonb, -- non-secret metadata only, see §5 + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (source_type, source_ref) +); + +create table adapter_credentials ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + adapter_type text not null check (adapter_type in ('github', 'linear')), + ciphertext bytea not null, + iv bytea not null, + auth_tag bytea not null, + key_version int not null default 1, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (project_id, adapter_type) +); + +create table tracks ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + title text not null, + status text not null default 'on_track' check (status in ('on_track', 'pivot_pending', 'blocked', 'done')), -- stored, see §3.5; written only by kt_create_track (§3.6) and kt_record_decision (§3.10) + source_doc_ref text, + last_github_sync_at timestamptz, + last_linear_sync_at timestamptz, + created_at timestamptz not null default now() +); +create index on tracks (project_id); + +create table track_dependencies ( + track_id uuid not null references tracks(id) on delete cascade, + depends_on_track_id uuid not null references tracks(id) on delete cascade, + primary key (track_id, depends_on_track_id), + check (track_id <> depends_on_track_id) +); + +create table items ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + track_id uuid not null references tracks(id) on delete cascade, + title text not null, + status text not null default 'pending' check (status in ('pending', 'in_progress', 'done', 'blocked')), + sequence_position int not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index on items (track_id); +create index on items (project_id); + +create table item_dependencies ( + item_id uuid not null references items(id) on delete cascade, + depends_on_item_id uuid not null references items(id) on delete cascade, + primary key (item_id, depends_on_item_id), + check (item_id <> depends_on_item_id) +); + +create table events ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + track_id uuid not null references tracks(id) on delete cascade, + event_type text not null default 'session_summary' check (event_type in ('session_summary')), + summary_text text not null, + files_touched jsonb not null default '[]'::jsonb, + items_touched uuid[] not null default '{}', + created_at timestamptz not null default now() +); +create index on events (track_id, created_at desc); +create index on events (project_id, created_at desc); + +create table decisions ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + track_id uuid not null references tracks(id) on delete cascade, + title text not null, + rationale text not null, + what_changed text not null, + created_at timestamptz not null default now() +); +create index on decisions (project_id, created_at desc); + +create table drift_flags ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + track_id uuid references tracks(id) on delete cascade, + item_id uuid references items(id) on delete cascade, + flag_type text not null check (flag_type in + ('STALE_TRACK', 'DEPENDENCY_GAP', 'SEQUENCE_SKIP', 'UNDOCUMENTED_DECISION', 'ORPHAN_ITEM', 'SYNC_DRIFT')), + severity text not null check (severity in ('info', 'warning', 'critical')), + detail text not null, + status text not null default 'open' check (status in ('open', 'resolved', 'dismissed')), + raised_at timestamptz not null default now(), + resolved_at timestamptz +); +create index on drift_flags (project_id, status, raised_at desc); +``` + +Note on `items.status`: **item status is stored** and is the terminal write target of `kt_update_item_status`, which can set it to any of the four values. `tracks.status` is also stored (§3.5), but with a narrower set of writers: only `kt_create_track` (initial value) and `kt_record_decision` (→ `pivot_pending`) ever write it — there is no tool analogous to `kt_update_item_status` for tracks. + +--- + +## Appendix B — Drift Flag Catalog (`kt_check_drift`, `kt_record_session_summary`'s scoped re-check) + +| `flag_type` | `severity` | Trigger condition | +|---|---|---| +| `STALE_TRACK` | `warning` | Track's stored status is `on_track` **and** no `session_summary` event referencing that `track_id` has `created_at` within the last `KNOTRACK_STALE_TRACK_DAYS` days (default 14). If the track has zero events ever, measured from the track's `created_at` instead. | +| `DEPENDENCY_GAP` | `critical` | An item has `status = 'done'` while at least one of its `depends_on_item_id` items does **not** have `status = 'done'`. (Should be prevented at write time by `kt_update_item_status`'s 409 check — this flag exists as a defensive integrity check, e.g. for data that predates that rule or was touched directly in the DB.) | +| `SEQUENCE_SKIP` | `info` | An item with `sequence_position = k` and `status = 'done'` exists while another item in the **same track** with `sequence_position < k` has `status` of `pending` or `blocked` — i.e. work finished out of its intended order. Informational, not necessarily wrong. | +| `UNDOCUMENTED_DECISION` | `warning` | A `decisions` row exists for a track, and **no** `events` row for that same `track_id` has `created_at` later than the decision's `created_at` — i.e. a decision was logged but no subsequent session summary shows it was acted on. | +| `ORPHAN_ITEM` | `warning` | An item's `depends_on_item_id` points to an item belonging to a **different** `track_id` than the item itself. (Should be prevented at write time by `kt_create_item`'s same-track restriction — defensive check only, e.g. for imported/migrated data.) | +| `SYNC_DRIFT` | `warning` | The project has credentials configured for an adapter (a row exists in `adapter_credentials` for `github` and/or `linear`), and the track's `updated_at`-equivalent (most recent item status change or event on that track) is later than its `last_github_sync_at` / `last_linear_sync_at` respectively — i.e. local state has moved since the last successful sync. `last_github_sync_at`/`last_linear_sync_at` are updated only on a successful (`{ok: true}`) `kt_sync_to_github`/`kt_sync_to_linear` call. | + +--- + +## Appendix C — Track & Item Status Enums (reference) + +- **Item status** (`items.status`, stored): `pending` | `in_progress` | `done` | `blocked`. Set only via `kt_update_item_status`; defaults to `pending` at creation. +- **Track status** (`tracks.status`, stored): `on_track` | `pivot_pending` | `blocked` | `done`. Set at creation (`kt_create_track`) and by `kt_record_decision` (→ `pivot_pending`); no other tool changes it. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3eb34e8 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,43 @@ +// ESLint 9 flat config. +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: [ + 'eslint.config.js', + 'vitest.config.ts', + 'vitest.stryker.config.ts', + 'stryker.conf.json', + ], + }, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], + }, + }, + { + files: ['tests/**/*.ts', 'scripts/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + }, + }, +); diff --git a/migrations/001_init.down.sql b/migrations/001_init.down.sql new file mode 100644 index 0000000..6658c5a --- /dev/null +++ b/migrations/001_init.down.sql @@ -0,0 +1,77 @@ +-- KnoTrack — 001_init.down.sql +-- Exact reverse of 001_init.sql. Drops objects in reverse dependency +-- order so no DROP ever fails on a still-referencing foreign key. +-- +-- Note: DROP TABLE implicitly drops that table's own indexes, +-- constraints, and triggers, so they are not dropped individually +-- below — only objects that are NOT owned by a single table (the +-- shared trigger function) get an explicit DROP. + +BEGIN; + +-- ============================================================ +-- drift_flags +-- ============================================================ +DROP TABLE IF EXISTS drift_flags; + +-- ============================================================ +-- api_tokens +-- ============================================================ +DROP TABLE IF EXISTS api_tokens; + +-- ============================================================ +-- decisions +-- ============================================================ +DROP TABLE IF EXISTS decisions; + +-- ============================================================ +-- events +-- ============================================================ +DROP TABLE IF EXISTS events; + +-- ============================================================ +-- item_dependencies +-- ============================================================ +DROP TABLE IF EXISTS item_dependencies; + +-- ============================================================ +-- items +-- ============================================================ +DROP TABLE IF EXISTS items; + +-- ============================================================ +-- track_dependencies +-- ============================================================ +DROP TABLE IF EXISTS track_dependencies; + +-- ============================================================ +-- tracks +-- ============================================================ +DROP TABLE IF EXISTS tracks; + +-- ============================================================ +-- adapters +-- ============================================================ +DROP TABLE IF EXISTS adapters; + +-- ============================================================ +-- projects +-- ============================================================ +DROP TABLE IF EXISTS projects; + +-- ============================================================ +-- Shared trigger function +-- ============================================================ +DROP FUNCTION IF EXISTS set_updated_at(); + +-- ============================================================ +-- Extensions +-- ============================================================ +-- Deliberately NOT dropped: pgcrypto is a database-wide extension that +-- other schemas/migrations in the same database may depend on, and +-- DROP EXTENSION here would be an out-of-band, hard-to-reverse +-- decision for a migration whose job is just to undo 001_init's own +-- objects. Uncomment only if you are certain nothing else uses it: +-- DROP EXTENSION IF EXISTS pgcrypto; + +COMMIT; diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..bb48d07 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,255 @@ +-- KnoTrack — 001_init.sql +-- Initial schema migration. Plain SQL, runnable as a node-pg-migrate +-- raw-SQL migration (node-pg-migrate --migrations-dir migrations, with +-- a migration named 001_init.sql / 001_init.down.sql pair auto-detected +-- as the up/down halves of migration 001_init). +-- +-- Design choices are explained in ../docs/DATABASE_SCHEMA.md. Summary: +-- * All enumerated fields use `text` + `CHECK (... IN (...))`, not +-- native Postgres `CREATE TYPE ... AS ENUM`. See "Enum vs text+CHECK" +-- in the doc for the justification. This choice is applied +-- consistently across every status/type/kind column below. +-- * Every project-owned child table cascades on project delete at the +-- FK level, but KnoTrack never issues that DELETE from the +-- application in normal operation — projects are soft-deleted via +-- `projects.deleted_at` to preserve the Event/Decision audit trail. +-- See "Soft delete vs hard delete" in the doc. +-- * uuid primary keys are generated with gen_random_uuid() from +-- pgcrypto, which is broadly available (built-in or via extension) +-- across supported Postgres versions (13+). + +BEGIN; + +-- ============================================================ +-- Extensions +-- ============================================================ + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- ============================================================ +-- Shared trigger function: keep updated_at current on UPDATE +-- ============================================================ + +CREATE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- projects +-- ============================================================ + +CREATE TABLE projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + source_type text NOT NULL CHECK (source_type IN ('github', 'linear', 'local')), + source_ref text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz +); + +CREATE TRIGGER trg_projects_set_updated_at + BEFORE UPDATE ON projects + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- Hot path: "list my active projects" / most lookups exclude soft-deleted rows. +CREATE INDEX idx_projects_not_deleted ON projects (id) WHERE deleted_at IS NULL; + +-- ============================================================ +-- adapters +-- ============================================================ + +CREATE TABLE adapters ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('github', 'linear')), + encrypted_credential bytea NOT NULL, + config jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + -- at most one adapter of a given type per project + CONSTRAINT uq_adapters_project_type UNIQUE (project_id, type) +); + +CREATE INDEX idx_adapters_project_id ON adapters (project_id); + +-- ============================================================ +-- tracks +-- ============================================================ + +CREATE TABLE tracks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + title text NOT NULL, + status text NOT NULL DEFAULT 'on_track' + CHECK (status IN ('on_track', 'pivot_pending', 'blocked', 'done')), + source_doc_ref text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_tracks_set_updated_at + BEFORE UPDATE ON tracks + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_tracks_project_id ON tracks (project_id); + +-- ============================================================ +-- track_dependencies (track A depends on track B) +-- ============================================================ + +CREATE TABLE track_dependencies ( + track_id uuid NOT NULL REFERENCES tracks (id) ON DELETE CASCADE, + depends_on_track_id uuid NOT NULL REFERENCES tracks (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (track_id, depends_on_track_id), + CONSTRAINT ck_track_dependencies_no_self_dep CHECK (track_id <> depends_on_track_id) +); + +-- Reverse-lookup index: "which tracks depend on this one" (e.g. for +-- cascading pivot/blocked status or cycle-detection walks). +CREATE INDEX idx_track_dependencies_depends_on ON track_dependencies (depends_on_track_id); + +-- ============================================================ +-- items +-- ============================================================ + +CREATE TABLE items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + track_id uuid NOT NULL REFERENCES tracks (id) ON DELETE CASCADE, + title text NOT NULL, + sequence_position integer NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_progress', 'done', 'blocked')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TRIGGER trg_items_set_updated_at + BEFORE UPDATE ON items + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE INDEX idx_items_track_id ON items (track_id); +-- Ordered-fetch hot path: "give me this track's items in sequence". +CREATE INDEX idx_items_track_id_sequence_position ON items (track_id, sequence_position); + +-- ============================================================ +-- item_dependencies (item A depends on item B) +-- ============================================================ + +CREATE TABLE item_dependencies ( + item_id uuid NOT NULL REFERENCES items (id) ON DELETE CASCADE, + depends_on_item_id uuid NOT NULL REFERENCES items (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (item_id, depends_on_item_id), + CONSTRAINT ck_item_dependencies_no_self_dep CHECK (item_id <> depends_on_item_id) +); + +CREATE INDEX idx_item_dependencies_depends_on ON item_dependencies (depends_on_item_id); + +-- ============================================================ +-- events (append-only audit trail) +-- ============================================================ + +CREATE TABLE events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + track_id uuid REFERENCES tracks (id) ON DELETE SET NULL, + summary_text text NOT NULL, + files_touched jsonb NOT NULL DEFAULT '[]'::jsonb, + items_touched jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() + -- No updated_at column, deliberately: events are append-only. See the + -- "Append-only tables" note in the schema doc for the REVOKE UPDATE + -- suggestion for locked-down deployments. +); + +CREATE INDEX idx_events_project_id ON events (project_id); +CREATE INDEX idx_events_track_id ON events (track_id); + +-- ============================================================ +-- decisions (append-only audit trail) +-- ============================================================ + +CREATE TABLE decisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + track_id uuid REFERENCES tracks (id) ON DELETE SET NULL, + title text NOT NULL, + rationale text, + what_changed text, + created_at timestamptz NOT NULL DEFAULT now() + -- Append-only, same convention as events. No updated_at. +); + +CREATE INDEX idx_decisions_project_id ON decisions (project_id); +CREATE INDEX idx_decisions_track_id ON decisions (track_id); + +-- ============================================================ +-- api_tokens +-- ============================================================ + +CREATE TABLE api_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid REFERENCES projects (id) ON DELETE CASCADE, + token_hash text NOT NULL, + label text, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + -- project_id is nullable: NULL means a server-wide token (not scoped + -- to a single project); non-null means a project-scoped token. + CONSTRAINT uq_api_tokens_token_hash UNIQUE (token_hash) +); + +-- Bearer-auth hot path: every authenticated request looks up by hash. +-- (uq_api_tokens_token_hash above already creates a unique index that +-- serves this lookup; project_id is indexed separately for the +-- "list tokens for a project" admin view.) +CREATE INDEX idx_api_tokens_project_id ON api_tokens (project_id); + +-- ============================================================ +-- drift_flags +-- ============================================================ + +CREATE TABLE drift_flags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + track_id uuid REFERENCES tracks (id) ON DELETE SET NULL, + item_id uuid REFERENCES items (id) ON DELETE SET NULL, + kind text NOT NULL CHECK (kind IN ('out_of_sequence', 'orphan_file_change')), + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + raised_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz +); + +CREATE INDEX idx_drift_flags_project_id ON drift_flags (project_id); +CREATE INDEX idx_drift_flags_track_id ON drift_flags (track_id); +CREATE INDEX idx_drift_flags_item_id ON drift_flags (item_id); + +-- Hot path for kt_get_project_status: "open drift flags for this +-- project". Partial index keeps it small and fast as resolved flags +-- accumulate over the project's lifetime. +CREATE INDEX idx_drift_flags_open_by_project ON drift_flags (project_id) WHERE resolved_at IS NULL; + +-- ============================================================ +-- Locked-down deployment hardening (optional, NOT executed here) +-- ============================================================ +-- KnoTrack's application code never UPDATEs events or decisions rows. +-- For a deployment that wants this enforced at the database level +-- rather than by convention, run something like the following against +-- the role your application connects as (substitute the real role +-- name — this is commented out because the role does not exist by +-- default and the statement would fail the migration otherwise): +-- +-- REVOKE UPDATE ON events, decisions FROM knotrack_app; +-- +-- INSERT and SELECT remain granted; only UPDATE is revoked, so the +-- append-only invariant is enforced by the database, not just by the +-- application layer. + +COMMIT; diff --git a/migrations/002_projects_unique_source_ref.down.sql b/migrations/002_projects_unique_source_ref.down.sql new file mode 100644 index 0000000..b46d990 --- /dev/null +++ b/migrations/002_projects_unique_source_ref.down.sql @@ -0,0 +1,8 @@ +-- KnoTrack — 002_projects_unique_source_ref.down.sql +-- Exact reverse of 002_projects_unique_source_ref.sql. + +BEGIN; + +DROP INDEX IF EXISTS uq_projects_source_ref_active; + +COMMIT; diff --git a/migrations/002_projects_unique_source_ref.sql b/migrations/002_projects_unique_source_ref.sql new file mode 100644 index 0000000..9af35c2 --- /dev/null +++ b/migrations/002_projects_unique_source_ref.sql @@ -0,0 +1,32 @@ +-- KnoTrack — 002_projects_unique_source_ref.sql +-- +-- Fixes adversarial-review finding correctness-3: registerProjectService's +-- upsert (find-then-insert, both inside one transaction) had no database +-- constraint backing the documented "(source_type, source_ref) is unique; +-- calling kt_register_project again with the same pair updates the +-- existing row, never creates a duplicate" invariant (docs/TRD.md §3.2). +-- Two concurrent first-registrations of the same source_ref could both +-- pass the SELECT check and both INSERT, producing two projects with the +-- same source identity. +-- +-- A partial unique index (scoped to non-soft-deleted rows, matching every +-- other "active project" lookup in this schema) turns that race into a +-- database-enforced constraint: the second concurrent INSERT now fails +-- with a 23505 unique-violation instead of silently succeeding. See the +-- corresponding change in src/db/queries/projects.ts, which turns +-- insertProject into an atomic `INSERT ... ON CONFLICT ... DO UPDATE` +-- targeting this exact index, so the race resolves to the documented +-- upsert behavior instead of an unhandled error. +-- +-- source_ref is nullable (source_type='local' projects have no natural +-- external ref) — Postgres unique indexes treat NULLs as distinct from +-- each other, so multiple local projects with source_ref IS NULL remain +-- unaffected by this constraint, which is the correct behavior. + +BEGIN; + +CREATE UNIQUE INDEX uq_projects_source_ref_active + ON projects (source_type, source_ref) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/migrations/003_drift_flags_open_unique.down.sql b/migrations/003_drift_flags_open_unique.down.sql new file mode 100644 index 0000000..5585e4c --- /dev/null +++ b/migrations/003_drift_flags_open_unique.down.sql @@ -0,0 +1,8 @@ +-- KnoTrack — 003_drift_flags_open_unique.down.sql +-- Exact reverse of 003_drift_flags_open_unique.sql. + +BEGIN; + +DROP INDEX IF EXISTS uq_drift_flags_open_item_kind; + +COMMIT; diff --git a/migrations/003_drift_flags_open_unique.sql b/migrations/003_drift_flags_open_unique.sql new file mode 100644 index 0000000..8e11cc1 --- /dev/null +++ b/migrations/003_drift_flags_open_unique.sql @@ -0,0 +1,34 @@ +-- KnoTrack — 003_drift_flags_open_unique.sql +-- +-- Fixes adversarial-review finding: kt_record_session_summary's scoped +-- drift re-check (src/mcp/tools/record-session-summary.ts) used to decide +-- whether to raise a new flag with a plain check-then-insert +-- (hasOpenFlagForItem, then insertDriftFlag) — no database constraint +-- backed the "at most one open flag per (item_id, kind)" invariant the +-- rest of the system assumes. Two concurrent kt_record_session_summary +-- calls scanning the same out-of-sequence item could both observe +-- alreadyOpen === false and both insert an open flag for it. +-- +-- A partial unique index (scoped to open flags — resolved_at IS NULL — +-- matching this schema's existing convention for "active row" partial +-- indexes, e.g. migrations/002's uq_projects_source_ref_active) turns +-- that race into a database-enforced constraint: the second concurrent +-- INSERT now conflicts instead of silently succeeding. See the +-- corresponding change in src/db/queries/drift-flags.ts, which turns the +-- check-then-insert into an atomic `INSERT ... ON CONFLICT ... DO NOTHING` +-- targeting this exact index. +-- +-- item_id is nullable (drift_flags.item_id references items ON DELETE SET +-- NULL) — Postgres unique indexes treat NULLs as distinct from each other, +-- so multiple resolved-at-null rows with item_id IS NULL would remain +-- unaffected by this constraint. That's fine: every kind this build ever +-- raises ('out_of_sequence', via the only insert call site) always sets +-- item_id, so this index does cover the real invariant it's meant to. + +BEGIN; + +CREATE UNIQUE INDEX uq_drift_flags_open_item_kind + ON drift_flags (item_id, kind) + WHERE resolved_at IS NULL; + +COMMIT; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a7d0d2a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6792 @@ +{ + "name": "knotrack", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "knotrack", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.0", + "fastify": "^5.2.0", + "pg": "^8.13.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/vitest-runner": "^10.0.0", + "@types/node": "^22.10.5", + "@types/pg": "^8.11.10", + "eslint": "^9.18.0", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.19.1", + "vitest": "^3.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.0.tgz", + "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", + "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.0", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "lru-cache": "^11.0.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.1.tgz", + "integrity": "sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.0", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.0.tgz", + "integrity": "sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.1.tgz", + "integrity": "sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-8.0.2.tgz", + "integrity": "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-decorators": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-8.0.1.tgz", + "integrity": "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz", + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.1.tgz", + "integrity": "sha512-RtR8uLDl0QcCmqMNIkM8gmDeYZ3rS0ZH+sa+I6sfc09yFoqfp9AEPgBstq9KyfVb0lFCVSRFfJXCI70FIl5ccw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.1.tgz", + "integrity": "sha512-VzDIYwBlLCpV6mJfloRdJm8HmYnMqs7O+bGha8yfg2kP7jAdxeCw6yZBVBeaKKQUThtSU52iy+3lB7DhYsbOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-8.0.1.tgz", + "integrity": "sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-8.0.1.tgz", + "integrity": "sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-8.0.1.tgz", + "integrity": "sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-8.0.1.tgz", + "integrity": "sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.1.tgz", + "integrity": "sha512-0Svqp3413Eg0GElldykF/T7SNsxQO5YVGD70fZyAdZTnX8WRgcopmbiU7GTa5xY5ZnJcEpNbfns8/GjX+/1yeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-syntax-typescript": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-8.0.1.tgz", + "integrity": "sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-react-display-name": "^8.0.1", + "@babel/plugin-transform-react-jsx": "^8.0.1", + "@babel/plugin-transform-react-jsx-development": "^8.0.1", + "@babel/plugin-transform-react-pure-annotations": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.1.tgz", + "integrity": "sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-typescript": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.3.tgz", + "integrity": "sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.2.tgz", + "integrity": "sha512-Y5/bAScMy5Y+9isCx0SKbyJebMCaXXX5em0kxkj115eZNscgV9srOHrgyfS0e5xAVymIfOh9piYBKDILktsMMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^12.0.0", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.2.0.tgz", + "integrity": "sha512-SKXarWrYhtpqOEctf9XGCGy29QjsvJAM0Aq9ZR9z4Ns94OmpqudOly+aSEfNqUf9SwsQaUgY9+Z8hyzG0xX8fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.0.tgz", + "integrity": "sha512-+nnvFEXIB08CZNVXpvW3B+zHW96QXvGUjNKJ8NJIPqAZi5Kd4WhYt2S3C234ReepG1qw2HOlEUbjYVHBowXObA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.0.tgz", + "integrity": "sha512-nnsP/IdJ8s83q7ZuObmgn12QM+uLCkab9E0Oordojbn62WUg1c+v9Ou/F/057pgh0ppX0W+Hj5bO/Dp5hsxQtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/external-editor": "^3.0.4", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.2.tgz", + "integrity": "sha512-OWIH1IyyWqEKIyqC9Xy+Bnga7NkGMovFdo4atYZMUOTRqf6rO2WCv9E/1MyzvOErDBCxs+9UFliRUDc50xs/jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.4.tgz", + "integrity": "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", + "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.3.tgz", + "integrity": "sha512-F/BZHtyEzP+HO+IGVd4AjBRgvX/ywm42bx8S0+dENk2YclzE9tJ3X/15THwtT6ehApmKvdYDMsVTuyyDod0gOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.0.tgz", + "integrity": "sha512-ew+fSDijsQ/WhD4TV3XLb+if400cDuzTzHfGR8sTNBXkK9CYDWoGE8fhaO8GbT312pNv1AJEOsDxy/z/HVettA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.2.tgz", + "integrity": "sha512-nSdufycW8xynEVssFkNQEYIzTySilog0UlfOVRwh3pXzPSk4frXUT2jZWjHnKae6RU9PaoF9wfy1pGwewQuqGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.6.0.tgz", + "integrity": "sha512-WgBVDRy3IQ4v9XMCpQ1YDGpso2PcMUxYJzZdH4Nt4t0eoXhEPmOCh5iZbXbR4GTbdUB9VPWBbJB12rkjbaGDCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.2", + "@inquirer/confirm": "^6.2.0", + "@inquirer/editor": "^5.3.0", + "@inquirer/expand": "^5.1.2", + "@inquirer/input": "^5.1.3", + "@inquirer/number": "^4.2.0", + "@inquirer/password": "^5.1.2", + "@inquirer/rawlist": "^5.3.2", + "@inquirer/search": "^4.3.0", + "@inquirer/select": "^5.2.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.2.tgz", + "integrity": "sha512-oPSKrYK1X1bMkjXDzIKHUkJp195LFSfgbnVtXnjSKGFjrCbS6I+wyvfAZTwKE9BSt3HwWgfD7JfsXALBgCogzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.0.tgz", + "integrity": "sha512-HFxXE5w727ctSUcAwrDquftJGjMgu36OeV5SHEXMlr2j/ahzmRX9xSEeVolV8tzYnTf45cg6vGkdMMRdm3RPhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.0", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.2.tgz", + "integrity": "sha512-RkI8dRHWt+bh04oLixvF1kFzKC7e5rqJoHKkzcqSHATebBXFC6GmrT8ddbVkgSzLV0HnHs2cPFuBINr8otij8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^12.0.0", + "@inquirer/figures": "^2.0.8", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/api": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz", + "integrity": "sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-10.0.0.tgz", + "integrity": "sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^8.0.0", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/instrumenter": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "ajv": "~8.20.0", + "chalk": "~5.6.0", + "commander": "~14.0.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.6.0", + "execa": "~9.6.0", + "json-rpc-2.0": "^1.7.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~10.2.4", + "mutation-server-protocol": "~0.4.0", + "mutation-testing-elements": "3.8.4", + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.3.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-10.0.0.tgz", + "integrity": "sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~8.0.0", + "@babel/generator": "~8.0.0", + "@babel/parser": "~8.0.0", + "@babel/plugin-proposal-decorators": "~8.0.0", + "@babel/plugin-transform-explicit-resource-management": "^8.0.0", + "@babel/preset-react": "~8.0.0", + "@babel/preset-typescript": "~8.0.0", + "@babel/traverse": "~8.0.4", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "angular-html-parser": "~10.11.0", + "semver": "~7.8.0", + "tslib": "2.8.1", + "weapon-regex": "~2.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-10.0.0.tgz", + "integrity": "sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stryker-mutator/vitest-runner": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/vitest-runner/-/vitest-runner-10.0.0.tgz", + "integrity": "sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "semver": "^7.7.4", + "tslib": "~2.8.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@stryker-mutator/core": "10.0.0", + "vitest": ">=2.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "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", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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", + "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", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/angular-html-parser": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.11.0.tgz", + "integrity": "sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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", + "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.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "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", + "engines": { + "node": ">=6.6.0" + } + }, + "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", + "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", + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, + "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/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "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", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.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", + "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.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "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/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "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" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.3.tgz", + "integrity": "sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fastify": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.1.tgz", + "integrity": "sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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", + "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/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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", + "engines": { + "node": ">= 0.8" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "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/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "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", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-rpc-2.0": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", + "integrity": "sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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" + }, + "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" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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", + "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/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/mutation-server-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mutation-server-protocol/-/mutation-server-protocol-0.4.1.tgz", + "integrity": "sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.1.12" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mutation-server-protocol/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/mutation-testing-elements": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.8.4.tgz", + "integrity": "sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.8.4.tgz", + "integrity": "sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.8.4" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.8.4.tgz", + "integrity": "sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "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/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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", + "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "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", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.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", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/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", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "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", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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", + "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/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "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", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "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", + "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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "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/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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", + "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", + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "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/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "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/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.3.1.tgz", + "integrity": "sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "6.15.1", + "tunnel": "0.0.6", + "underscore": "^1.13.8" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "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/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/weapon-regex": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.4.tgz", + "integrity": "sha512-ubuhY5Lo4phWcMsJqe8j62m9uhsuo/VpfK5XsSgYGRGDSt10hwVwOBTriPRd0dac+KYbGNXOrfXjM6xCp2NUKg==", + "dev": true, + "license": "Apache-2.0" + }, + "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", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "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", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ab7b9b --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "knotrack", + "version": "0.1.0", + "private": true, + "description": "KnoTrack — a self-hosted MCP server for durable, cross-session project tracking (not an orchestrator).", + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": ">=20.12" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "dev": "tsx src/index.ts", + "start": "node dist/src/index.js", + "migrate": "tsx scripts/migrate.ts", + "generate-token": "tsx scripts/generate-token.ts", + "seed-self": "tsx scripts/seed-self.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:mutation": "stryker run", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.0", + "fastify": "^5.2.0", + "pg": "^8.13.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.18.0", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/vitest-runner": "^10.0.0", + "@types/node": "^22.10.5", + "@types/pg": "^8.11.10", + "eslint": "^9.18.0", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.19.1", + "vitest": "^3.0.2" + }, + "overrides": { + "qs": "^6.15.2" + } +} diff --git a/scripts/generate-token.ts b/scripts/generate-token.ts new file mode 100644 index 0000000..9810ff0 --- /dev/null +++ b/scripts/generate-token.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env tsx +// Prints one new candidate bearer token for KNOTRACK_API_TOKENS +// (docs/TRD.md §4). Never written anywhere — stdout only. +import crypto from 'node:crypto'; + +function generateToken(): string { + // 256 bits (32 bytes) of entropy per docs/PRD.md, encoded as 43 URL-safe + // base64 characters, prefixed "kt_" — convention only, not enforced by + // the auth check itself (TRD §4). + const raw = crypto.randomBytes(32).toString('base64url'); // 32 bytes -> 43 base64url chars + return `kt_${raw}`; +} + +console.log(generateToken()); diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..e83265b --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env tsx +// Thin migration runner. +// +// TRD §1 mandates node-pg-migrate as the migrations tool. This repo's +// only migration (migrations/001_init.sql / 001_init.down.sql) already +// exists as a hand-written raw-SQL up/down pair rather than a file +// node-pg-migrate's own `migrate create` produced — node-pg-migrate's SQL +// mode expects specific timestamp-prefixed naming/pairing conventions +// from that command, and retrofitting an existing pair onto it risked +// fighting the tool's own bookkeeping for zero behavioral benefit on a +// single-migration repo. Resolution: a small, idempotent custom runner +// that tracks applied migrations in a `schema_migrations` table and +// applies each `.sql` file (in filename order) transactionally, +// exactly the "minimal custom runner if that's simpler" option this +// build was explicitly permitted to take. +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client, type ClientBase } from 'pg'; +import { loadDotEnvIfPresent } from '../src/config/load-dotenv.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = path.resolve(__dirname, '..', 'migrations'); + +// Allows (and preserves) a run of blank lines and `-- ...` comment lines — +// e.g. a migration file's descriptive header — before the leading `BEGIN;`. +// Group 1 captures that leading run so it survives the strip below; only +// the `BEGIN;` token itself (plus trailing whitespace) is removed. +const LEADING_BEGIN = /^((?:\s|--[^\r\n]*(?:\r?\n|$))*)BEGIN;\s*/i; +const TRAILING_COMMIT = /\s*COMMIT;\s*$/i; + +// Session-level Postgres advisory lock key for the migration pass as a +// whole (CodeRabbit re-review: two concurrent `npm run migrate` invocations +// could both read schema_migrations, both pick the same pending file, and +// one fails hitting objects the other already created). A single fixed +// bigint identifies "the KnoTrack migration runner" across every process +// that calls applyMigrations against the same database, regardless of +// migrations directory or file contents — it's derived once (FNV-1a 64 +// hash of the literal string "knotrack_migrations", folded into the signed +// 64-bit range pg_advisory_lock's `bigint` parameter requires) and then +// hardcoded so no hashing happens at runtime. +export const MIGRATION_ADVISORY_LOCK_KEY = -2365753259700777648n; + +/** + * Strips a migration file's own leading `BEGIN;` / trailing `COMMIT;` so + * the runner can wrap the DDL and its `schema_migrations` bookkeeping row + * in one transaction it controls itself (see the call site below). + * + * adversarial-review P2: each migration file commits its own DDL (via its + * embedded BEGIN/COMMIT) before this runner's separate + * `INSERT INTO schema_migrations` statement — two separate transactions. + * A process death between them leaves the schema changed but no record of + * it, so the same file re-runs next time and fails on objects that + * already exist. + * + * CodeRabbit re-review regression: the original LEADING_BEGIN pattern + * only tolerated whitespace before `BEGIN;`, so any migration starting + * with a `-- ...` comment header (e.g. + * migrations/003_drift_flags_open_unique.sql) failed this function's own + * "must start with BEGIN;" check and threw, even though the file is a + * perfectly valid BEGIN/COMMIT-wrapped migration. LEADING_BEGIN now allows + * and preserves that header instead of requiring it to be absent. + */ +function stripTransactionWrapper(sql: string, file: string): string { + if (!LEADING_BEGIN.test(sql) || !TRAILING_COMMIT.test(sql)) { + throw new Error( + `migration ${file} must start with "BEGIN;" (optionally preceded by blank lines and ` + + '"-- ..." comment lines) and end with "COMMIT;" — the runner strips those to wrap the ' + + 'DDL and its schema_migrations row in one transaction it controls', + ); + } + return sql.replace(LEADING_BEGIN, '$1').replace(TRAILING_COMMIT, ''); +} + +/** + * Applies every not-yet-applied `.sql` file in `migrationsDir` (in + * filename order) to `client`, atomically per file (see + * stripTransactionWrapper's doc comment). Extracted from `main()` so it's + * callable directly against a test database and a scratch migrations + * directory, independent of process.env/process.exit. + */ +export async function applyMigrations(client: ClientBase, migrationsDir: string): Promise { + // Session-level advisory lock spanning the entire pass (acquired before + // even the pending-migrations read, released only once every migration in + // this run has been applied or the pass has failed) so two concurrent + // runner invocations against the same database serialize instead of + // racing to apply the same file. This is a session lock, not a + // transaction-scoped one, so it's acquired/released explicitly rather + // than via BEGIN/COMMIT. + await client.query('SELECT pg_advisory_lock($1)', [MIGRATION_ADVISORY_LOCK_KEY]); + try { + await client.query( + `CREATE TABLE IF NOT EXISTS schema_migrations ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`, + ); + + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql') && !f.endsWith('.down.sql')) + .sort(); + + const appliedResult = await client.query<{ name: string }>( + 'SELECT name FROM schema_migrations', + ); + const applied = new Set(appliedResult.rows.map((r) => r.name)); + + let appliedCount = 0; + for (const file of files) { + if (applied.has(file)) { + console.log(`skip (already applied): ${file}`); + continue; + } + const sql = readFileSync(path.join(migrationsDir, file), 'utf8'); + const ddl = stripTransactionWrapper(sql, file); + console.log(`applying: ${file}`); + // Runner-controlled transaction wrapping both the DDL and the + // schema_migrations row, so a process death mid-migration can never + // leave one committed without the other (see stripTransactionWrapper's + // doc comment). The file's own BEGIN/COMMIT (stripped above) still + // makes it independently runnable via psql. + await client.query('BEGIN'); + try { + // Multi-statement DDL runs as a single simple-query call, which + // node-postgres's simple query protocol supports. + await client.query(ddl); + await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK').catch(() => { + /* rollback failure is secondary to the original error */ + }); + throw error; + } + appliedCount += 1; + } + + return appliedCount; + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_ADVISORY_LOCK_KEY]).catch(() => { + /* unlock failure is secondary to whatever the try block already threw/returned; + the lock is session-scoped, so it's also released automatically when this + client's connection eventually closes. */ + }); + } +} + +async function main(): Promise { + loadDotEnvIfPresent(); + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL is required to run migrations'); + } + + const sslMode = process.env.DATABASE_SSL_MODE ?? 'disable'; + // Same fix as src/db/pool.ts (adversarial-review security-2/data_privacy-1): + // verify the server's TLS certificate by default; only an explicit + // KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED=false opts out, for a broken/ + // self-signed local dev certificate. + const sslRejectUnauthorized = process.env.KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED !== 'false'; + const client = new Client({ + connectionString: databaseUrl, + ssl: sslMode === 'require' ? { rejectUnauthorized: sslRejectUnauthorized } : undefined, + }); + await client.connect(); + + try { + const appliedCount = await applyMigrations(client, MIGRATIONS_DIR); + if (appliedCount === 0) { + console.log('no pending migrations — schema already up to date'); + } else { + console.log(`applied ${appliedCount} migration(s)`); + } + } finally { + await client.end(); + } +} + +// Only auto-run when this file is the process entrypoint (`tsx +// scripts/migrate.ts` / `node dist/scripts/migrate.js`), not when +// `applyMigrations` is imported elsewhere (e.g. by tests) — importing an +// ES module always executes its top-level code, so without this guard +// every import of this file would also run a real migration against +// `process.env.DATABASE_URL`, `process.exit()` included. +const isMainModule = process.argv[1] === fileURLToPath(import.meta.url); +if (isMainModule) { + main().catch((error: unknown) => { + console.error('migration failed:', error); + process.exit(1); + }); +} diff --git a/scripts/seed-self.ts b/scripts/seed-self.ts new file mode 100644 index 0000000..aa513c6 --- /dev/null +++ b/scripts/seed-self.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env tsx +// KnoTrack's own dogfood seed. Assumes the server/DB are already up (the +// migration has been applied — see scripts/migrate.ts) and calls the 5 +// implemented tools' underlying service functions directly, in-process — +// simpler and more direct than round-tripping through HTTP for a one-shot +// seed script. +// +// What this does, matching docs/ROADMAP.md's T1: +// (a) registers KnoTrack itself as a project +// (source_type: "github", source_ref: "pjpoulose/knotrack") +// (b) creates Track 1 ("Spec sign-off") +// (c) creates its 6 items (T1.1-T1.6) +// (d) records one session summary describing this build session +// +// This is the dogfooding step: running it leaves real rows in the local +// Postgres database proving KnoTrack tracked its own first real session. +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadDotEnvIfPresent } from '../src/config/load-dotenv.js'; +import { loadConfig } from '../src/config/env.js'; +import { createPool } from '../src/db/pool.js'; +import { registerProjectService } from '../src/mcp/tools/register-project.js'; +import { createTrackService } from '../src/mcp/tools/create-track.js'; +import { createItemService } from '../src/mcp/tools/create-item.js'; +import { recordSessionSummaryService } from '../src/mcp/tools/record-session-summary.js'; +import { getProjectStatusService } from '../src/mcp/tools/get-project-status.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); + +/** Files touched during this build session: every doc under docs/, the + * migration pair, and every scaffold file under src/scripts/tests plus + * the top-level project config files — walked from disk so the list is + * always accurate rather than hand-maintained. */ +function listTrackedFiles(): string[] { + const roots = ['docs', 'migrations', 'src', 'scripts', 'tests']; + const topLevelFiles = [ + 'package.json', + 'tsconfig.json', + 'eslint.config.js', + '.prettierrc.json', + '.env.example', + 'Dockerfile', + 'vitest.config.ts', + ]; + + const files: string[] = [...topLevelFiles]; + + const walk = (relDir: string): void => { + const absDir = path.join(REPO_ROOT, relDir); + for (const entry of readdirSync(absDir)) { + const relPath = path.join(relDir, entry); + const absPath = path.join(REPO_ROOT, relPath); + const stat = statSync(absPath); + if (stat.isDirectory()) { + if (entry === 'node_modules' || entry === 'dist' || entry === 'coverage') continue; + walk(relPath); + } else { + files.push(relPath); + } + } + }; + + for (const root of roots) { + walk(root); + } + + return files.sort(); +} + +const T1_ITEMS = [ + 'T1.1 — PRD finalized and approved', + 'T1.2 — TRD finalized and approved', + 'T1.3 — Architecture doc finalized', + 'T1.4 — DB schema finalized', + 'T1.5 — Test case matrix authored for all 14 MCP tools', + 'T1.6 — Cross-document consistency pass', +]; + +async function main(): Promise { + loadDotEnvIfPresent(); + const config = loadConfig(); + const pool = createPool(config); + + try { + console.log('(a) registering KnoTrack itself as a project...'); + const { project_id } = await registerProjectService(pool, config, { + name: 'KnoTrack', + source_type: 'github', + source_ref: 'pjpoulose/knotrack', + adapters: undefined, + }); + console.log(` project_id = ${project_id}`); + + console.log('(b) creating Track 1 ("Spec sign-off")...'); + const { track_id } = await createTrackService(pool, config, { + project_id, + title: 'Spec sign-off', + depends_on: [], + source_doc_ref: 'docs/ROADMAP.md#t1--spec-sign-off', + }); + console.log(` track_id = ${track_id}`); + + console.log('(c) creating T1.1-T1.6 items...'); + const itemIds: string[] = []; + for (const title of T1_ITEMS) { + const { item_id } = await createItemService(pool, config, { + project_id, + track_id, + title, + sequence_position: undefined, + depends_on: [], + }); + itemIds.push(item_id); + console.log(` ${title} -> ${item_id}`); + } + + console.log('(d) recording session summary for this build session...'); + const filesTouched = listTrackedFiles(); + const { event_id, drift_flags_raised } = await recordSessionSummaryService(pool, config, { + project_id, + track_id, + summary_text: + 'Initial spec package (PRD/TRD/Architecture/DB schema/test cases/roadmap) authored ' + + 'and reconciled; initial 5-tool server scaffold built and tested against local Postgres.', + files_touched: filesTouched, + items_touched: itemIds, + }); + console.log(` event_id = ${event_id}`); + if (drift_flags_raised.length > 0) { + console.log(` drift_flags_raised = ${JSON.stringify(drift_flags_raised)}`); + } + + console.log('\nVerifying via kt_get_project_status...'); + const status = await getProjectStatusService(pool, config, { project_id }); + console.log(JSON.stringify(status, null, 2)); + + console.log('\nSeed complete. KnoTrack has now tracked its own first real session.'); + } finally { + await pool.end(); + } +} + +main().catch((error: unknown) => { + console.error('seed-self failed:', error); + process.exit(1); +}); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..11ca000 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,117 @@ +// zod schema for process.env -> typed Config object. +// See docs/TRD.md §7 for the full env var table this mirrors. +import { z } from 'zod'; + +const base64Bytes = (expectedLength: number) => + z.string().refine( + (value) => { + try { + return Buffer.from(value, 'base64').length === expectedLength; + } catch { + return false; + } + }, + { message: `must be base64 encoding exactly ${expectedLength} bytes` }, + ); + +const envSchema = z.object({ + DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + KNOTRACK_API_TOKENS: z + .string() + .min(1, 'KNOTRACK_API_TOKENS is required and must not be empty') + .transform((value) => + value + .split(',') + .map((token) => token.trim()) + .filter((token) => token.length > 0), + ) + .refine((tokens) => tokens.length > 0, { + message: 'KNOTRACK_API_TOKENS must contain at least one token', + }), + KNOTRACK_ENCRYPTION_KEY: base64Bytes(32), + NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), + PORT: z.coerce.number().int().positive().default(8080), + HOST: z.string().default('0.0.0.0'), + DATABASE_SSL_MODE: z.enum(['require', 'disable']).optional(), + // Defaults to true: verify the Postgres server's TLS certificate against + // trusted CAs. The only reason to ever set this to false is a broken/ + // self-signed local dev cert — see docs/TRD.md §... (adversarial-review + // finding security-2/data_privacy-1: this used to be hardcoded to false + // whenever SSL was required, silently accepting any certificate). + KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED: z + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), + KNOTRACK_DB_STATEMENT_TIMEOUT_MS: z.coerce.number().int().positive().default(30000), + KNOTRACK_DB_POOL_MAX: z.coerce.number().int().positive().default(10), + KNOTRACK_DRIFT_SCAN_TRACK_CAP: z.coerce.number().int().positive().default(500), + KNOTRACK_DRIFT_SCAN_ITEM_CAP: z.coerce.number().int().positive().default(5000), + KNOTRACK_DRIFT_SCAN_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), + KNOTRACK_ROADMAP_TRACK_CAP: z.coerce.number().int().positive().default(200), + KNOTRACK_ROADMAP_ITEM_PER_TRACK_CAP: z.coerce.number().int().positive().default(100), + KNOTRACK_STALE_TRACK_DAYS: z.coerce.number().int().positive().default(14), + KNOTRACK_NEXT_STEPS_LIMIT: z.coerce.number().int().positive().default(5), + KNOTRACK_GITHUB_SYNC_TIMEOUT_MS: z.coerce.number().int().positive().default(8000), + KNOTRACK_LINEAR_SYNC_TIMEOUT_MS: z.coerce.number().int().positive().default(8000), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), +}); + +export type Config = { + databaseUrl: string; + apiTokens: string[]; + encryptionKey: Buffer; + nodeEnv: 'development' | 'production' | 'test'; + port: number; + host: string; + databaseSslMode: 'require' | 'disable'; + dbSslRejectUnauthorized: boolean; + dbStatementTimeoutMs: number; + dbPoolMax: number; + driftScanTrackCap: number; + driftScanItemCap: number; + driftScanTimeoutMs: number; + roadmapTrackCap: number; + roadmapItemPerTrackCap: number; + staleTrackDays: number; + nextStepsLimit: number; + githubSyncTimeoutMs: number; + linearSyncTimeoutMs: number; + logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; +}; + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const parsed = envSchema.safeParse(env); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) + .join('\n'); + throw new Error(`Invalid environment configuration:\n${issues}`); + } + const data = parsed.data; + const nodeEnv = data.NODE_ENV; + const databaseSslMode = + data.DATABASE_SSL_MODE ?? (nodeEnv === 'production' ? 'require' : 'disable'); + + return { + databaseUrl: data.DATABASE_URL, + apiTokens: data.KNOTRACK_API_TOKENS, + encryptionKey: Buffer.from(data.KNOTRACK_ENCRYPTION_KEY, 'base64'), + nodeEnv, + port: data.PORT, + host: data.HOST, + databaseSslMode, + dbSslRejectUnauthorized: data.KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED, + dbStatementTimeoutMs: data.KNOTRACK_DB_STATEMENT_TIMEOUT_MS, + dbPoolMax: data.KNOTRACK_DB_POOL_MAX, + driftScanTrackCap: data.KNOTRACK_DRIFT_SCAN_TRACK_CAP, + driftScanItemCap: data.KNOTRACK_DRIFT_SCAN_ITEM_CAP, + driftScanTimeoutMs: data.KNOTRACK_DRIFT_SCAN_TIMEOUT_MS, + roadmapTrackCap: data.KNOTRACK_ROADMAP_TRACK_CAP, + roadmapItemPerTrackCap: data.KNOTRACK_ROADMAP_ITEM_PER_TRACK_CAP, + staleTrackDays: data.KNOTRACK_STALE_TRACK_DAYS, + nextStepsLimit: data.KNOTRACK_NEXT_STEPS_LIMIT, + githubSyncTimeoutMs: data.KNOTRACK_GITHUB_SYNC_TIMEOUT_MS, + linearSyncTimeoutMs: data.KNOTRACK_LINEAR_SYNC_TIMEOUT_MS, + logLevel: data.LOG_LEVEL, + }; +} diff --git a/src/config/load-dotenv.ts b/src/config/load-dotenv.ts new file mode 100644 index 0000000..3b76d09 --- /dev/null +++ b/src/config/load-dotenv.ts @@ -0,0 +1,45 @@ +// Loads a local `.env` file into process.env when one is present, without +// adding a `dotenv` dependency. +// +// adversarial-review finding: the quick-start docs (README) tell a local +// developer to `cp .env.example .env` and edit it, but nothing ever +// actually read that file — `tsx`/npm don't load `.env` implicitly, so +// every value in it was silently ignored outside a shell that happened to +// export it manually. +// +// This can't be fixed by adding `--env-file=.env` to the affected npm +// scripts: docs/TRD.md §7/§8 document `npm run migrate` as the exact +// command Render/Railway/Fly run in production, where the real config +// comes from platform-injected env vars and no `.env` file exists at all +// — `node --env-file=.env` hard-fails (and exits) when the named file is +// missing, which would break every one of those deploys. The newer +// `--env-file-if-exists` flag would dodge that, but it needs Node >=22.9, +// newer than this repo's `engines.node` and its Dockerfile's `node:20.20-slim` +// base actually support. `process.loadEnvFile()` (Node >=20.12) gives the +// same "load it if present, otherwise carry on" behavior at the JS level +// instead, so every entrypoint below calls it unconditionally and it's a +// no-op whenever the file just doesn't exist. +// +// CodeRabbit re-review: `engines.node` used to only declare `>=20`, which +// technically allows 20.0–20.11 — versions with no `loadEnvFile` at all — +// on which `.env` was silently ignored. `engines.node` now declares +// `>=20.12` (this function's actual floor) so that gap is a declared +// version violation rather than a silent no-op; the `typeof !== 'function'` +// branch below still falls through safely as defense-in-depth for anyone +// who runs this outside the declared engines range regardless. +export function loadDotEnvIfPresent(): void { + const loadEnvFile = (process as unknown as { loadEnvFile?: (path?: string) => void }).loadEnvFile; + if (typeof loadEnvFile !== 'function') { + // Node <20.12: no built-in loader available. Falling through leaves + // process.env exactly as the shell provided it — the same behavior + // this repo had before this fix, just no worse. + return; + } + try { + loadEnvFile.call(process); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw error; + } + } +} diff --git a/src/crypto/credential-cipher.ts b/src/crypto/credential-cipher.ts new file mode 100644 index 0000000..ca43713 --- /dev/null +++ b/src/crypto/credential-cipher.ts @@ -0,0 +1,42 @@ +// AES-256-GCM encrypt/decrypt for adapter credentials (TRD §5). +// +// Schema note: TRD §5's Appendix A describes a dedicated +// `adapter_credentials` table with separate `ciphertext`/`iv`/`auth_tag` +// columns. The authoritative, already-applied migration +// (migrations/001_init.sql, documented in docs/DATABASE_SCHEMA.md) instead +// has a single `adapters.encrypted_credential bytea` column with no +// sibling iv/auth_tag columns. Resolution: pack `iv (12 bytes) || +// authTag (16 bytes) || ciphertext` into that one column. This preserves +// every guarantee TRD §5 cares about (fresh random IV per secret, key +// never touches Postgres, generic 500 on tamper/wrong-key) while fitting +// the real, already-migrated schema instead of one this scaffold is not +// authorized to alter. +import crypto from 'node:crypto'; + +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const ALGORITHM = 'aes-256-gcm'; + +export function encryptCredential(plaintext: string, key: Buffer): Buffer { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, ciphertext]); +} + +export function decryptCredential(packed: Buffer, key: Buffer): string { + if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error('encrypted_credential blob is too short to contain iv+authTag'); + } + const iv = packed.subarray(0, IV_LENGTH); + const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + // authTagLength is passed explicitly (not left to default) so setAuthTag + // enforces exactly 16 bytes rather than accepting a truncated tag — + // this is the fix for javascript.node-crypto.security.gcm-no-tag-length. + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return plaintext.toString('utf8'); +} diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..c0bcac5 --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,48 @@ +// pg.Pool singleton, sized from KNOTRACK_DB_POOL_MAX (TRD §6.2). +import { Pool, type PoolConfig } from 'pg'; +import type { Config } from '../config/env.js'; + +let pool: Pool | undefined; + +export function createPool(config: Config, overrides: Partial = {}): Pool { + const poolConfig: PoolConfig = { + connectionString: config.databaseUrl, + max: config.dbPoolMax, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + // statement_timeout bounds how long any single query can run before + // Postgres itself cancels it, so a slow/deadlocked query can't hang a + // request (and the connection it's holding) indefinitely. + statement_timeout: config.dbStatementTimeoutMs, + ...overrides, + }; + if (config.databaseSslMode === 'require') { + // rejectUnauthorized defaults to true (verify the server's TLS cert + // against trusted CAs) — only KNOTRACK_DB_SSL_REJECT_UNAUTHORIZED=false + // disables it, for a broken/self-signed local dev certificate. Never + // hardcode this to false: doing so keeps the channel encrypted but + // accepts any certificate, which is silently vulnerable to MITM. + poolConfig.ssl = { rejectUnauthorized: config.dbSslRejectUnauthorized }; + } + return new Pool(poolConfig); +} + +/** Process-wide singleton, initialized once at boot via initPool(). */ +export function initPool(config: Config): Pool { + pool = createPool(config); + return pool; +} + +export function getPool(): Pool { + if (!pool) { + throw new Error('DB pool not initialized — call initPool(config) at process startup'); + } + return pool; +} + +export async function closePool(): Promise { + if (pool) { + await pool.end(); + pool = undefined; + } +} diff --git a/src/db/queries/adapters.ts b/src/db/queries/adapters.ts new file mode 100644 index 0000000..89062ed --- /dev/null +++ b/src/db/queries/adapters.ts @@ -0,0 +1,46 @@ +// `adapters` table access. Non-secret config (repo, team_id, etc.) is +// stored in `config jsonb`; the encrypted secret is packed into +// `encrypted_credential bytea` by src/crypto/credential-cipher.ts. +// See that module's header comment for why this doesn't match TRD §5's +// Appendix A `adapter_credentials` table shape. +import type { Pool, PoolClient } from 'pg'; + +type Queryable = Pool | PoolClient; + +export async function upsertAdapter( + db: Queryable, + input: { + projectId: string; + type: 'github' | 'linear'; + encryptedCredential: Buffer; + config: Record; + }, +): Promise { + await db.query( + `INSERT INTO adapters (project_id, type, encrypted_credential, config) + VALUES ($1, $2, $3, $4) + ON CONFLICT (project_id, type) + DO UPDATE SET encrypted_credential = EXCLUDED.encrypted_credential, + config = EXCLUDED.config`, + [input.projectId, input.type, input.encryptedCredential, JSON.stringify(input.config)], + ); +} + +export interface AdapterRow { + id: string; + project_id: string; + type: 'github' | 'linear'; + encrypted_credential: Buffer; + config: Record; + created_at: Date; +} + +export async function listAdaptersForProject( + db: Queryable, + projectId: string, +): Promise { + const result = await db.query(`SELECT * FROM adapters WHERE project_id = $1`, [ + projectId, + ]); + return result.rows; +} diff --git a/src/db/queries/drift-flags.ts b/src/db/queries/drift-flags.ts new file mode 100644 index 0000000..0cd5f56 --- /dev/null +++ b/src/db/queries/drift-flags.ts @@ -0,0 +1,165 @@ +// `drift_flags` access. +// +// Schema note: TRD §3.12/Appendix C describe six `flag_type` values plus +// a `severity` and a `status` ('open'/'resolved'/'dismissed') column. The +// authoritative, already-applied migration (migrations/001_init.sql) has +// a `kind` column restricted by CHECK to exactly two values +// ('out_of_sequence', 'orphan_file_change'), no `severity` column, and +// "open" is represented by `resolved_at IS NULL` rather than a status +// enum. This module maps onto the real columns: +// - `flag_type` in tool output = `kind` (upper-cased, TRD-style) +// - `severity` is derived from a fixed kind -> severity table, since +// the DB doesn't store one +// - `status` in tool output = 'open' when resolved_at IS NULL, else +// 'resolved' +// kt_check_drift itself (which would run the full six-rule catalog) is +// out of scope for this build (stub only, see src/mcp/tools/check-drift.ts) +// — only the two DB-representable kinds are ever written here, by +// kt_record_session_summary's scoped re-check. +import type { Pool, PoolClient } from 'pg'; + +type Queryable = Pool | PoolClient; + +export type DriftKind = 'out_of_sequence' | 'orphan_file_change'; + +const SEVERITY_BY_KIND: Record = { + out_of_sequence: 'info', + orphan_file_change: 'warning', +}; + +// adversarial-review P1: `row.kind.toUpperCase()` produced 'OUT_OF_SEQUENCE' +// for the DB kind, but TRD Appendix C names this public flag_type +// 'SEQUENCE_SKIP' (see this module's header comment) — a client switching +// on flag_type by the documented name could never match it. An explicit +// per-kind mapping (rather than a string transform) makes the public name +// independent of the DB's internal spelling, and a missing case is a +// compile error instead of a silently-wrong uppercase guess. +const PUBLIC_FLAG_TYPE_BY_KIND: Record = { + out_of_sequence: 'SEQUENCE_SKIP', + // No TRD Appendix C flag_type corresponds to this DB kind — it isn't + // raised anywhere in this build (reserved for kt_check_drift's future + // orphan-file-change rule, out of scope here). Kept as a distinct, + // clearly-DB-shaped name rather than silently aliased to one of the six + // real flag_types it doesn't actually mean. + orphan_file_change: 'ORPHAN_FILE_CHANGE', +}; + +export interface DriftFlagRow { + id: string; + project_id: string; + track_id: string | null; + item_id: string | null; + kind: DriftKind; + detail: Record; + raised_at: Date; + resolved_at: Date | null; +} + +export interface DriftFlagView { + flag_id: string; + flag_type: string; + severity: 'info' | 'warning' | 'critical'; + track_id: string | null; + item_id: string | null; + detail: string; + status: 'open' | 'resolved'; + raised_at: Date; +} + +function toView(row: DriftFlagRow): DriftFlagView { + return { + flag_id: row.id, + flag_type: PUBLIC_FLAG_TYPE_BY_KIND[row.kind], + severity: SEVERITY_BY_KIND[row.kind], + track_id: row.track_id, + item_id: row.item_id, + detail: typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail), + status: row.resolved_at ? 'resolved' : 'open', + raised_at: row.raised_at, + }; +} + +/** + * Raises a new open flag for (item_id, kind) unless one is already open, + * atomically. Returns the inserted row, or `null` when an open flag for + * this (item_id, kind) already existed (nothing inserted). + * + * adversarial-review P1: this used to be a separate `hasOpenFlagForItem` + * check followed by a plain `INSERT` — two concurrent + * kt_record_session_summary calls scanning the same out-of-sequence item + * could both observe "not open yet" and both insert, producing duplicate + * open flags for the same item. `ON CONFLICT ... DO NOTHING` against the + * `uq_drift_flags_open_item_kind` partial unique index (migrations/003) + * makes this check-and-insert atomic at the database level: at most one of + * two concurrent callers ever gets a row back. + */ +export async function insertDriftFlagIfNotOpen( + db: Queryable, + input: { + projectId: string; + trackId: string | null; + itemId: string | null; + kind: DriftKind; + detail: Record; + }, +): Promise { + const result = await db.query( + `INSERT INTO drift_flags (project_id, track_id, item_id, kind, detail) + VALUES ($1, $2, $3, $4, $5::jsonb) + ON CONFLICT (item_id, kind) WHERE resolved_at IS NULL + DO NOTHING + RETURNING *`, + [input.projectId, input.trackId, input.itemId, input.kind, JSON.stringify(input.detail)], + ); + return result.rows[0] ?? null; +} + +export async function listOpenDriftFlags( + db: Queryable, + projectId: string, + limit: number, +): Promise { + const result = await db.query( + `SELECT * FROM drift_flags + WHERE project_id = $1 AND resolved_at IS NULL + ORDER BY raised_at DESC + LIMIT $2`, + [projectId, limit], + ); + return result.rows.map(toView); +} + +/** Open flags of one kind, scoped to a track — used by the scoped + * re-check to know which items currently have an open flag, both to skip + * re-raising for them and to resolve the ones whose condition cleared. */ +export async function listOpenFlagsForTrack( + db: Queryable, + trackId: string, + kind: DriftKind, +): Promise { + const result = await db.query( + `SELECT * FROM drift_flags WHERE track_id = $1 AND kind = $2 AND resolved_at IS NULL`, + [trackId, kind], + ); + return result.rows; +} + +/** + * Marks the given flags resolved (sets resolved_at = now()). No-op for an + * empty list. + * + * adversarial-review P1: nothing anywhere ever wrote `resolved_at` — once + * an item's out-of-sequence condition cleared (the earlier item was also + * finished), its flag stayed open indefinitely and kt_get_project_status + * kept reporting it. Called by the scoped re-check with exactly the open + * flags whose item is no longer among the current findings. + */ +export async function resolveDriftFlags(db: Queryable, flagIds: string[]): Promise { + if (flagIds.length === 0) return; + await db.query( + `UPDATE drift_flags SET resolved_at = now() WHERE id = ANY($1::uuid[]) AND resolved_at IS NULL`, + [flagIds], + ); +} + +export { toView as driftFlagToView }; diff --git a/src/db/queries/events.ts b/src/db/queries/events.ts new file mode 100644 index 0000000..5b1a53c --- /dev/null +++ b/src/db/queries/events.ts @@ -0,0 +1,96 @@ +import type { Pool, PoolClient } from 'pg'; + +type Queryable = Pool | PoolClient; + +export interface EventRow { + id: string; + project_id: string; + track_id: string | null; + summary_text: string; + files_touched: string[]; + items_touched: string[]; + created_at: Date; +} + +export async function insertEvent( + db: Queryable, + input: { + projectId: string; + trackId: string; + summaryText: string; + filesTouched: string[]; + itemsTouched: string[]; + }, +): Promise { + const result = await db.query( + `INSERT INTO events (project_id, track_id, summary_text, files_touched, items_touched) + VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) + RETURNING *`, + [ + input.projectId, + input.trackId, + input.summaryText, + JSON.stringify(input.filesTouched), + JSON.stringify(input.itemsTouched), + ], + ); + const row = result.rows[0]; + if (!row) throw new Error('insertEvent: INSERT ... RETURNING produced no row'); + return row; +} + +/** Was a session_summary event recorded for this track within the last + * N days? Used by the STALE_TRACK-equivalent check. Returns the most + * recent event's created_at, or null if none exist at all. */ +export async function getMostRecentEventForTrack( + db: Queryable, + trackId: string, +): Promise { + const result = await db.query<{ created_at: Date }>( + `SELECT created_at FROM events WHERE track_id = $1 ORDER BY created_at DESC LIMIT 1`, + [trackId], + ); + return result.rows[0]?.created_at ?? null; +} + +export interface TimelineEntry { + event_id: string; + event_type: 'session_summary' | 'decision'; + track_id: string | null; + summary_text: string; + created_at: Date; +} + +/** + * TRD §3.3 asks kt_get_project_status's `recent_events` to union + * session_summary events and decision events, newest first. The real + * schema (migrations/001_init.sql) keeps those as two separate tables + * (`events`, `decisions`) with no shared `event_type` discriminator + * column and no single `summary_text` field on `decisions` — so this + * query synthesizes both: a decision row's `title` stands in for + * `summary_text` (decisions have no free-text field that plays the same + * role events' summary_text does). + */ +export async function getRecentTimeline( + db: Queryable, + projectId: string, + limit: number, +): Promise { + const result = await db.query( + `( + SELECT id AS event_id, 'session_summary'::text AS event_type, track_id, summary_text, created_at + FROM events + WHERE project_id = $1 + ) + UNION ALL + ( + SELECT id AS event_id, 'decision'::text AS event_type, track_id, title AS summary_text, created_at + FROM decisions + WHERE project_id = $1 + ) + ORDER BY created_at DESC + LIMIT $2`, + [projectId, limit], + ); + return result.rows; +} diff --git a/src/db/queries/items.ts b/src/db/queries/items.ts new file mode 100644 index 0000000..aaf5d07 --- /dev/null +++ b/src/db/queries/items.ts @@ -0,0 +1,127 @@ +import type { Pool, PoolClient } from 'pg'; +import type { Edge } from '../../domain/dependency-graph.js'; + +type Queryable = Pool | PoolClient; + +export type ItemStatus = 'pending' | 'in_progress' | 'done' | 'blocked'; + +export interface ItemRow { + id: string; + track_id: string; + title: string; + sequence_position: number; + status: ItemStatus; + created_at: Date; + updated_at: Date; +} + +export async function getMaxSequencePosition(db: Queryable, trackId: string): Promise { + const result = await db.query<{ max: number | null }>( + `SELECT MAX(sequence_position) AS max FROM items WHERE track_id = $1`, + [trackId], + ); + return result.rows[0]?.max ?? 0; +} + +/** Locks the track row for the remainder of the caller's transaction. + * adversarial-review correctness-1: getMaxSequencePosition() followed by + * insertItem() is a read-then-write with no unique constraint backing it — + * two concurrent kt_create_item calls on the same track could both read + * the same MAX and insert the same sequence_position. Callers must take + * this lock (inside the same transaction, before computing the max) + * whenever they are about to auto-assign a sequence_position, so + * concurrent auto-assigns on one track serialize instead of racing. Must + * be called with a PoolClient already inside BEGIN/COMMIT — a bare Pool + * would release the lock immediately. */ +export async function lockTrackForSequenceAssignment( + db: PoolClient, + trackId: string, +): Promise { + await db.query(`SELECT id FROM tracks WHERE id = $1 FOR UPDATE`, [trackId]); +} + +/** Fetch a set of items by id regardless of track, keyed by id — used to + * distinguish "id doesn't exist as an item at all" (404) from "exists but + * wrong track" (422) per TRD §3.7 / §3.9. */ +export async function getItemsByIds(db: Queryable, ids: string[]): Promise> { + if (ids.length === 0) return new Map(); + const result = await db.query(`SELECT * FROM items WHERE id = ANY($1::uuid[])`, [ids]); + return new Map(result.rows.map((row) => [row.id, row])); +} + +export async function getItemDependencyEdgesForTrack( + db: Queryable, + trackId: string, +): Promise { + const result = await db.query<{ item_id: string; depends_on_item_id: string }>( + `SELECT id.item_id, id.depends_on_item_id + FROM item_dependencies id + JOIN items i ON i.id = id.item_id + WHERE i.track_id = $1`, + [trackId], + ); + return result.rows.map((row) => ({ from: row.item_id, to: row.depends_on_item_id })); +} + +/** + * Shifts every item at or after `fromPosition` one position later, making + * room to insert a new item at exactly `fromPosition`. Must be called + * inside the same transaction as the insert, after + * `lockTrackForSequenceAssignment` — same race this project already backs + * the auto-assign path with, since this is also a read-then-write + * (deciding which rows to shift) with no unique constraint on + * sequence_position to catch a concurrent collision otherwise. + */ +export async function shiftSequencePositionsFrom( + db: PoolClient, + trackId: string, + fromPosition: number, +): Promise { + await db.query( + `UPDATE items SET sequence_position = sequence_position + 1 + WHERE track_id = $1 AND sequence_position >= $2`, + [trackId, fromPosition], + ); +} + +export async function insertItem( + db: Queryable, + input: { trackId: string; title: string; sequencePosition: number }, +): Promise { + const result = await db.query( + `INSERT INTO items (track_id, title, sequence_position) + VALUES ($1, $2, $3) + RETURNING *`, + [input.trackId, input.title, input.sequencePosition], + ); + const row = result.rows[0]; + if (!row) throw new Error('insertItem: INSERT ... RETURNING produced no row'); + return row; +} + +export async function insertItemDependencies( + db: Queryable, + itemId: string, + dependsOn: string[], +): Promise { + const deduped = Array.from(new Set(dependsOn)); + if (deduped.length === 0) return; + const values: string[] = []; + const params: string[] = []; + deduped.forEach((depId, index) => { + values.push(`($1, $${index + 2})`); + params.push(depId); + }); + await db.query( + `INSERT INTO item_dependencies (item_id, depends_on_item_id) VALUES ${values.join(', ')}`, + [itemId, ...params], + ); +} + +export async function listItemsByTrack(db: Queryable, trackId: string): Promise { + const result = await db.query( + `SELECT * FROM items WHERE track_id = $1 ORDER BY sequence_position ASC`, + [trackId], + ); + return result.rows; +} diff --git a/src/db/queries/projects.ts b/src/db/queries/projects.ts new file mode 100644 index 0000000..6f10fde --- /dev/null +++ b/src/db/queries/projects.ts @@ -0,0 +1,54 @@ +import type { Pool, PoolClient } from 'pg'; + +export interface ProjectRow { + id: string; + name: string; + source_type: 'github' | 'linear' | 'local'; + source_ref: string | null; + created_at: Date; + updated_at: Date; + deleted_at: Date | null; +} + +type Queryable = Pool | PoolClient; + +export async function findActiveProjectById( + db: Queryable, + projectId: string, +): Promise { + const result = await db.query( + `SELECT * FROM projects WHERE id = $1 AND deleted_at IS NULL`, + [projectId], + ); + return result.rows[0] ?? null; +} + +/** + * Atomic upsert on (source_type, source_ref) via the `uq_projects_source_ref_active` + * partial unique index (migrations/002). Replaces the previous find-then-insert + * pattern, which had a TOCTOU race under concurrent calls with the same source + * identity (adversarial-review correctness-3) — two concurrent callers could + * both miss an existing row and both insert, producing duplicate projects. + * `INSERT ... ON CONFLICT ... DO UPDATE` resolves the race atomically at the + * database level: exactly one row ever exists per (source_type, source_ref) + * among non-soft-deleted projects, and the second concurrent caller updates + * the same row the first one created instead of erroring or duplicating. + */ +export async function upsertProjectBySourceRef( + db: Queryable, + input: { name: string; sourceType: string; sourceRef: string }, +): Promise { + const result = await db.query( + `INSERT INTO projects (name, source_type, source_ref) + VALUES ($1, $2, $3) + ON CONFLICT (source_type, source_ref) WHERE deleted_at IS NULL + DO UPDATE SET name = EXCLUDED.name + RETURNING *`, + [input.name, input.sourceType, input.sourceRef], + ); + const row = result.rows[0]; + if (!row) { + throw new Error('upsertProjectBySourceRef: INSERT ... RETURNING produced no row'); + } + return row; +} diff --git a/src/db/queries/tracks.ts b/src/db/queries/tracks.ts new file mode 100644 index 0000000..a58dd5c --- /dev/null +++ b/src/db/queries/tracks.ts @@ -0,0 +1,121 @@ +import type { Pool, PoolClient } from 'pg'; +import type { Edge } from '../../domain/dependency-graph.js'; + +type Queryable = Pool | PoolClient; + +export type TrackStatus = 'on_track' | 'pivot_pending' | 'blocked' | 'done'; + +export interface TrackRow { + id: string; + project_id: string; + title: string; + status: TrackStatus; + source_doc_ref: string | null; + created_at: Date; + updated_at: Date; +} + +export async function findTrackById( + db: Queryable, + projectId: string, + trackId: string, +): Promise { + const result = await db.query( + `SELECT * FROM tracks WHERE id = $1 AND project_id = $2`, + [trackId, projectId], + ); + return result.rows[0] ?? null; +} + +/** Status of every track in a project, keyed by id — used to validate + * depends_on ids exist and to check whether they're all `done`. */ +export async function getTrackStatusesForProject( + db: Queryable, + projectId: string, +): Promise> { + const result = await db.query<{ id: string; status: TrackStatus }>( + `SELECT id, status FROM tracks WHERE project_id = $1`, + [projectId], + ); + return new Map(result.rows.map((row) => [row.id, row.status])); +} + +/** All track_dependencies edges within a project, as {from, to} where + * `from` depends on `to` — the shape dependency-graph.ts expects. */ +export async function getTrackDependencyEdges(db: Queryable, projectId: string): Promise { + const result = await db.query<{ track_id: string; depends_on_track_id: string }>( + `SELECT td.track_id, td.depends_on_track_id + FROM track_dependencies td + JOIN tracks t ON t.id = td.track_id + WHERE t.project_id = $1`, + [projectId], + ); + return result.rows.map((row) => ({ from: row.track_id, to: row.depends_on_track_id })); +} + +export async function insertTrack( + db: Queryable, + input: { + projectId: string; + title: string; + status: TrackStatus; + sourceDocRef: string | undefined; + }, +): Promise { + const result = await db.query( + `INSERT INTO tracks (project_id, title, status, source_doc_ref) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [input.projectId, input.title, input.status, input.sourceDocRef ?? null], + ); + const row = result.rows[0]; + if (!row) throw new Error('insertTrack: INSERT ... RETURNING produced no row'); + return row; +} + +export async function insertTrackDependencies( + db: Queryable, + trackId: string, + dependsOn: string[], +): Promise { + const deduped = Array.from(new Set(dependsOn)); + if (deduped.length === 0) return; + const values: string[] = []; + const params: string[] = []; + deduped.forEach((depId, index) => { + values.push(`($1, $${index + 2})`); + params.push(depId); + }); + await db.query( + `INSERT INTO track_dependencies (track_id, depends_on_track_id) VALUES ${values.join(', ')}`, + [trackId, ...params], + ); +} + +export interface TrackWithCounts extends TrackRow { + pending: number; + in_progress: number; + done: number; + blocked: number; +} + +export async function listTracksWithItemCounts( + db: Queryable, + projectId: string, +): Promise { + const result = await db.query( + `SELECT + t.*, + COALESCE(SUM((i.status = 'pending')::int), 0)::int AS pending, + COALESCE(SUM((i.status = 'in_progress')::int), 0)::int AS in_progress, + COALESCE(SUM((i.status = 'done')::int), 0)::int AS done, + COALESCE(SUM((i.status = 'blocked')::int), 0)::int AS blocked + FROM tracks t + LEFT JOIN items i ON i.track_id = t.id + WHERE t.project_id = $1 + GROUP BY t.id + ORDER BY t.created_at ASC`, + [projectId], + ); + return result.rows; +} diff --git a/src/db/tx.ts b/src/db/tx.ts new file mode 100644 index 0000000..e62bba2 --- /dev/null +++ b/src/db/tx.ts @@ -0,0 +1,60 @@ +import type { Pool, PoolClient } from 'pg'; + +/** Runs `fn` inside a BEGIN/COMMIT transaction on a dedicated client, + * rolling back on any thrown error. Used by every write-path tool that + * touches more than one table (TRD calls out several "in the same + * transaction as" requirements — §3.6, §3.7, §3.9, §3.10). */ +export async function withTransaction( + pool: Pool, + fn: (client: PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK').catch(() => { + /* rollback failure is secondary to the original error */ + }); + throw error; + } finally { + client.release(); + } +} + +/** + * Runs `fn` inside a single REPEATABLE READ, read-only transaction on one + * dedicated client, so multiple reads see one consistent snapshot instead + * of each hitting its own pool connection/snapshot and potentially + * observing different points in time if a write commits in between. + * + * adversarial-review P2: kt_get_project_status's three roll-up queries + * used to run on `pool` directly (Promise.all over separate connections), + * so a concurrent commit landing mid-flight could produce a response + * mixing pre- and post-commit state that never represented any actual DB + * state at any instant. Used for read-only roll-ups only — nothing here + * is ever meant to write, hence READ ONLY. + */ +export async function withReadSnapshot( + pool: Pool, + fn: (client: PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + try { + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK').catch(() => { + /* rollback failure is secondary to the original error */ + }); + throw error; + } + } finally { + client.release(); + } +} diff --git a/src/domain/dependency-graph.ts b/src/domain/dependency-graph.ts new file mode 100644 index 0000000..df57f8f --- /dev/null +++ b/src/domain/dependency-graph.ts @@ -0,0 +1,76 @@ +// Topo-sort + cycle detection, shared by kt_create_track and +// kt_create_item (TRD §3.6, §3.7). An edge {from, to} means "from depends +// on to" — matching the depends_on_track_id / depends_on_item_id +// direction used everywhere else in the TRD. + +export interface Edge { + from: string; + to: string; +} + +/** + * Returns true if the directed graph described by `edges` contains a + * cycle reachable from any node. Uses a standard three-color DFS + * (white/gray/black) so it runs in O(V + E) regardless of how the edges + * are ordered. + */ +export function hasCycle(edges: Edge[]): boolean { + const adjacency = new Map(); + for (const edge of edges) { + const list = adjacency.get(edge.from); + if (list) { + list.push(edge.to); + } else { + adjacency.set(edge.from, [edge.to]); + } + if (!adjacency.has(edge.to)) { + adjacency.set(edge.to, []); + } + } + + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const node of adjacency.keys()) { + color.set(node, WHITE); + } + + const visit = (node: string): boolean => { + color.set(node, GRAY); + for (const next of adjacency.get(node) ?? []) { + const nextColor = color.get(next) ?? WHITE; + if (nextColor === GRAY) { + return true; // back edge -> cycle + } + if (nextColor === WHITE && visit(next)) { + return true; + } + } + color.set(node, BLACK); + return false; + }; + + for (const node of adjacency.keys()) { + if (color.get(node) === WHITE && visit(node)) { + return true; + } + } + return false; +} + +/** + * Convenience wrapper for the create-time check: given the existing + * dependency edges already in the DB for this scope (project's tracks, or + * one track's items), plus the proposed new node and its (de-duplicated) + * depends_on list, would inserting those new edges create a cycle? + */ +export function wouldCreateCycle( + existingEdges: Edge[], + newNodeId: string, + dependsOn: string[], +): boolean { + const deduped = Array.from(new Set(dependsOn)); + const proposedEdges: Edge[] = deduped.map((to) => ({ from: newNodeId, to })); + return hasCycle([...existingEdges, ...proposedEdges]); +} diff --git a/src/domain/drift-detector.ts b/src/domain/drift-detector.ts new file mode 100644 index 0000000..cd08a0d --- /dev/null +++ b/src/domain/drift-detector.ts @@ -0,0 +1,46 @@ +// The drift-flag rules (docs/TRD.md Appendix C / §6.3). Only the +// track-scoped SEQUENCE_SKIP-equivalent rule is implemented for real in +// this build, run by kt_record_session_summary (docs/TRD.md §3.9) right +// after it inserts the session's event row. +// +// Schema note: the real drift_flags.kind CHECK constraint +// (migrations/001_init.sql) only allows 'out_of_sequence' | +// 'orphan_file_change' — not TRD Appendix C's six flag_type values. Of +// those six, only SEQUENCE_SKIP has a direct match in the DB's allowed +// kinds ('out_of_sequence' — same semantics: an item finished while an +// earlier item in the same track is not done). The other five +// (STALE_TRACK, DEPENDENCY_GAP, UNDOCUMENTED_DECISION, ORPHAN_ITEM, +// SYNC_DRIFT) have no corresponding `kind` value the schema will accept, +// so they're left for kt_check_drift's future real implementation +// (out of scope here — see src/mcp/tools/check-drift.ts, a stub) rather +// than silently invented as extra allowed kind values on a migration this +// build must not alter. +import type { ItemRow } from '../db/queries/items.js'; + +export interface SequenceSkipFinding { + itemId: string; + detail: string; +} + +/** + * An item is "out of sequence" if it is `done` while at least one item + * earlier in the same track's sequence is still `pending` or `blocked`. + * Items must already be sorted by sequence_position ascending. + */ +export function findSequenceSkips(itemsBySequence: ItemRow[]): SequenceSkipFinding[] { + const findings: SequenceSkipFinding[] = []; + for (let i = 0; i < itemsBySequence.length; i += 1) { + const item = itemsBySequence[i]; + if (!item || item.status !== 'done') continue; + const earlierIncomplete = itemsBySequence + .slice(0, i) + .find((candidate) => candidate.status === 'pending' || candidate.status === 'blocked'); + if (earlierIncomplete) { + findings.push({ + itemId: item.id, + detail: `Item '${item.title}' (seq ${item.sequence_position}) is done while an earlier item '${earlierIncomplete.title}' (seq ${earlierIncomplete.sequence_position}) is still ${earlierIncomplete.status}.`, + }); + } + } + return findings; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c15520b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,48 @@ +// Process entrypoint: load config, run pre-flight checks, start Fastify. +import { loadDotEnvIfPresent } from './config/load-dotenv.js'; +import { loadConfig } from './config/env.js'; +import { initContext, getDb } from './mcp/context.js'; +import { buildFastify } from './server/fastify.js'; +import { closePool } from './db/pool.js'; + +async function main(): Promise { + loadDotEnvIfPresent(); + const config = loadConfig(); + initContext(config); + const pool = getDb(); + + // Pre-flight: fail fast on an unreachable DB rather than starting and + // serving 500s until the operator notices. + await pool.query('SELECT 1'); + + const instanceStartedAt = new Date(); + const app = buildFastify(pool, config, instanceStartedAt); + + await app.listen({ port: config.port, host: config.host }); + + // A second signal (or a signal arriving while the first is still + // closing) must not start a concurrent shutdown, and a rejection from + // either close call must not leave this an unhandled rejection with the + // process hanging until something sends SIGKILL. + let shuttingDown = false; + const shutdown = async (signal: string): Promise => { + if (shuttingDown) return; + shuttingDown = true; + app.log.info({ signal }, 'shutting down'); + try { + await app.close(); + await closePool(); + process.exit(0); + } catch (error) { + app.log.error({ err: error, signal }, 'error during shutdown'); + process.exit(1); + } + }; + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('SIGINT', () => void shutdown('SIGINT')); +} + +main().catch((error: unknown) => { + console.error('fatal startup error:', error); + process.exit(1); +}); diff --git a/src/mcp/context.ts b/src/mcp/context.ts new file mode 100644 index 0000000..8329436 --- /dev/null +++ b/src/mcp/context.ts @@ -0,0 +1,26 @@ +// Per-process request context: the DB pool and loaded Config, made +// available to tool handlers without threading them through every call +// signature. The MCP protocol revision this server targets (2026-07-28) +// is stateless — there is no per-session state here, only the two +// process-wide singletons (pool, config) every request reads. +import type { Pool } from 'pg'; +import type { Config } from '../config/env.js'; +import { getPool, initPool } from '../db/pool.js'; + +let config: Config | undefined; + +export function initContext(cfg: Config): void { + config = cfg; + initPool(cfg); +} + +export function getConfig(): Config { + if (!config) { + throw new Error('Context not initialized — call initContext(config) at process startup'); + } + return config; +} + +export function getDb(): Pool { + return getPool(); +} diff --git a/src/mcp/errors.ts b/src/mcp/errors.ts new file mode 100644 index 0000000..7f5b9eb --- /dev/null +++ b/src/mcp/errors.ts @@ -0,0 +1,61 @@ +// KtError class + ERROR_CODES map. See docs/TRD.md §3.1 for the exact +// envelope shape and the transport-level delivery rules (401 vs the rest). + +export const ERROR_CODES = { + UNAUTHORIZED: 401, + NOT_FOUND: 404, + CONFLICT: 409, + VALIDATION_ERROR: 422, + INTERNAL_ERROR: 500, +} as const; + +export type ErrorCode = keyof typeof ERROR_CODES; + +export interface KtErrorEnvelope { + error: { + code: ErrorCode; + http_status_equivalent: number; + message: string; + details?: Record; + }; +} + +export class KtError extends Error { + readonly code: ErrorCode; + readonly details: Record | undefined; + + constructor(code: ErrorCode, message: string, details?: Record) { + super(message); + this.name = 'KtError'; + this.code = code; + this.details = details; + } + + toEnvelope(): KtErrorEnvelope { + const errorBody: KtErrorEnvelope['error'] = { + code: this.code, + http_status_equivalent: ERROR_CODES[this.code], + message: this.message, + }; + if (this.details) { + errorBody.details = this.details; + } + return { error: errorBody }; + } +} + +export function notFound(message: string, details?: Record): KtError { + return new KtError('NOT_FOUND', message, details); +} + +export function conflict(message: string, details?: Record): KtError { + return new KtError('CONFLICT', message, details); +} + +export function validationError(message: string, details?: Record): KtError { + return new KtError('VALIDATION_ERROR', message, details); +} + +export function internalError(message: string, details?: Record): KtError { + return new KtError('INTERNAL_ERROR', message, details); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..7d98aaf --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,35 @@ +// Constructs the McpServer instance and registers the 14 canonical tools +// (docs/TRD.md §2). /health and /info are mounted as plain Fastify +// routes, not MCP tools — see src/server/health-route.ts. +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Pool } from 'pg'; +import type { Config } from '../config/env.js'; +import { registerProjectTool } from './tools/register-project.js'; +import { registerCreateTrackTool } from './tools/create-track.js'; +import { registerCreateItemTool } from './tools/create-item.js'; +import { registerGetProjectStatusTool } from './tools/get-project-status.js'; +import { registerRecordSessionSummaryTool } from './tools/record-session-summary.js'; +import { registerStubTools } from './tools/stubs.js'; + +export interface Logger { + error: (obj: unknown, msg?: string) => void; +} + +export function buildMcpServer(pool: Pool, config: Config, logger: Logger): McpServer { + const server = new McpServer({ + name: 'knotrack', + version: '0.1.0', + }); + + // 5 fully implemented tools. + registerProjectTool(server, pool, config, logger); + registerGetProjectStatusTool(server, pool, config, logger); + registerCreateTrackTool(server, pool, config, logger); + registerCreateItemTool(server, pool, config, logger); + registerRecordSessionSummaryTool(server, pool, config, logger); + + // 9 stubs — registered so tools/list reflects the full 14-tool surface. + registerStubTools(server); + + return server; +} diff --git a/src/mcp/tool-helpers.ts b/src/mcp/tool-helpers.ts new file mode 100644 index 0000000..7134544 --- /dev/null +++ b/src/mcp/tool-helpers.ts @@ -0,0 +1,104 @@ +// Shared plumbing for wiring a tool's service function into an +// @modelcontextprotocol/sdk CallToolResult, per the error-envelope +// delivery rules in docs/TRD.md §3.1. +// +// KNOWN LIMITATION (adversarial-review P1, confirmed real, not fixed — +// documented per this build's "don't guess at a fix that doesn't actually +// work" rule): `runTool` below only ever sees a request that already +// reached a tool's handler function. But `@modelcontextprotocol/sdk` +// (server/mcp.js's `setToolRequestHandlers`) validates `tools/call` +// arguments against the tool's `inputSchema` *before* calling that +// handler at all — a malformed call (unknown property, invalid UUID, a +// missing required field) never runs `fn()` here, so it never gets +// wrapped in the documented VALIDATION_ERROR envelope. Instead the SDK +// catches its own `McpError(InvalidParams, ...)` and returns +// `{ isError: true, content: [{ text: "Input validation error: ..." }] }` +// — an isError result, per MCP convention, but with the SDK's own plain- +// text message as `content[0].text`, not `JSON.stringify` of this +// module's envelope. A client parsing that text as JSON expecting +// `{ error: { code: 'VALIDATION_ERROR', ... } }` gets something else. +// +// This can't be intercepted per-tool: `inputSchema` validation is baked +// into the one `CallToolRequestSchema` handler `McpServer` installs +// internally for every registered tool, with no per-tool hook and no +// public option to reformat its error output. The only ways to change it +// are either of these, and both cost more than they're worth for what is, +// in effect, malformed-request handling for a spec-compliant client: +// 1. Register every tool with no `inputSchema` and hand-validate inside +// each handler instead — but the SDK also derives `tools/list`'s +// published JSON Schema from `inputSchema` (this repo's whole reason +// for having zod schemas as "the single source of truth", per +// src/schemas/tools.ts's header comment); dropping it would silently +// turn every one of the 14 tools' advertised schemas into an empty +// object, trading one bug for a bigger one. +// 2. Call `server.server.setRequestHandler(CallToolRequestSchema, ...)` +// again after registration to install a replacement dispatcher — +// `Protocol.setRequestHandler` is public API and does allow this, +// but doing it correctly means reimplementing everything the SDK's +// own dispatcher currently does (task-support handling, output-schema +// validation, disabled-tool checks, tool lookup) against its +// internal, undocumented `_registeredTools` map — forking a chunk of +// SDK-internal behavior to fix one error-formatting edge case, and +// fragile to break silently on an SDK upgrade. +// tests/integration/http.test.ts's closed-schema test asserts against the +// SDK's real (non-enveloped) text, not the documented envelope, for this +// exact reason. +import { KtError, internalError } from './errors.js'; + +export interface ToolTextResult { + [key: string]: unknown; + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; + structuredContent?: Record; +} + +/** + * Wraps a service call: success returns the JSON-serialized result (both + * as text content and structuredContent); a thrown KtError is packaged as + * the exact §3.1 envelope with isError: true; any other thrown error is + * logged server-side (never leaking driver text to the client) and + * surfaced as a generic INTERNAL_ERROR envelope. + */ +export async function runTool>( + logger: { error: (obj: unknown, msg?: string) => void }, + toolName: string, + fn: () => Promise, +): Promise { + try { + const result = await fn(); + return { + content: [{ type: 'text', text: JSON.stringify(result) }], + structuredContent: result, + }; + } catch (error) { + if (error instanceof KtError) { + return { + content: [{ type: 'text', text: JSON.stringify(error.toEnvelope()) }], + isError: true, + }; + } + logger.error({ err: error, tool: toolName }, 'unhandled error in tool handler'); + const envelope = internalError('an unexpected error occurred').toEnvelope(); + return { + content: [{ type: 'text', text: JSON.stringify(envelope) }], + isError: true, + }; + } +} + +/** A "not yet implemented" stub result for the 9 tools out of scope for + * this build (see the tool table in docs/TRD.md §2 / the repo layout). */ +export function notImplementedResult(toolName: string): ToolTextResult { + const envelope = { + error: { + code: 'INTERNAL_ERROR' as const, + http_status_equivalent: 500, + message: `${toolName} is registered but not yet implemented in this build`, + details: { tool: toolName }, + }, + }; + return { + content: [{ type: 'text', text: JSON.stringify(envelope) }], + isError: true, + }; +} diff --git a/src/mcp/tools/create-item.ts b/src/mcp/tools/create-item.ts new file mode 100644 index 0000000..d706b71 --- /dev/null +++ b/src/mcp/tools/create-item.ts @@ -0,0 +1,142 @@ +// kt_create_item — docs/TRD.md §3.7. +import type { Pool } from 'pg'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../../config/env.js'; +import { createItemInputSchema, type CreateItemInput } from '../../schemas/tools.js'; +import { findActiveProjectById } from '../../db/queries/projects.js'; +import { findTrackById } from '../../db/queries/tracks.js'; +import { + getItemDependencyEdgesForTrack, + getItemsByIds, + getMaxSequencePosition, + insertItem, + insertItemDependencies, + listItemsByTrack, + lockTrackForSequenceAssignment, + shiftSequencePositionsFrom, +} from '../../db/queries/items.js'; +import { wouldCreateCycle } from '../../domain/dependency-graph.js'; +import { withTransaction } from '../../db/tx.js'; +import { conflict, notFound, validationError } from '../errors.js'; +import { runTool } from '../tool-helpers.js'; + +export interface CreateItemOutput extends Record { + item_id: string; +} + +export async function createItemService( + pool: Pool, + _config: Config, + input: CreateItemInput, +): Promise { + const dependsOn = Array.from(new Set(input.depends_on)); + + return withTransaction(pool, async (client) => { + const project = await findActiveProjectById(client, input.project_id); + if (!project) { + throw notFound('project not found', { project_id: input.project_id }); + } + + const track = await findTrackById(client, input.project_id, input.track_id); + if (!track) { + throw notFound('track not found in this project', { + project_id: input.project_id, + track_id: input.track_id, + }); + } + + if (dependsOn.length > 0) { + const foundItems = await getItemsByIds(client, dependsOn); + const notFoundIds = dependsOn.filter((id) => !foundItems.has(id)); + if (notFoundIds.length > 0) { + throw notFound('one or more depends_on ids do not exist as items at all', { + missing_item_ids: notFoundIds, + }); + } + const wrongTrackIds = dependsOn.filter((id) => foundItems.get(id)?.track_id !== track.id); + if (wrongTrackIds.length > 0) { + throw validationError( + 'depends_on ids must belong to the same track as the item being created', + { wrong_track_item_ids: wrongTrackIds, track_id: track.id }, + ); + } + } + + // The new item doesn't have an id yet; use a sentinel that cannot + // collide with a real item id (adversarial-review correctness-2: an + // item actually inserted with this literal nil UUID — not possible via + // any tool today, since insertItem relies on gen_random_uuid(), but not + // enforced — would otherwise make this check produce a wrong result). + // Checked against every item in the track, matching create-track.ts's + // equivalent trackStatuses.has(sentinel) check against every track in + // the project, not just ones with dependency edges. + const sentinel = '00000000-0000-0000-0000-000000000000'; + const trackItems = await listItemsByTrack(client, track.id); + if (trackItems.some((item) => item.id === sentinel)) { + throw validationError('internal sentinel collision — retry'); + } + const existingEdges = await getItemDependencyEdgesForTrack(client, track.id); + if (wouldCreateCycle(existingEdges, sentinel, dependsOn)) { + throw conflict('dependency cycle detected', { + track_id: track.id, + depends_on: dependsOn, + }); + } + + // adversarial-review correctness-1: lock the track row before reading + // the current max so two concurrent auto-assigns on the same track + // serialize instead of both computing the same next position. + let sequencePosition: number; + if (input.sequence_position !== undefined) { + // adversarial-review P1: an explicit sequence_position already used + // by another item in this track used to be inserted unchanged, + // producing two items that declare the same position — the schema + // has no uniqueness constraint on sequence_position to prevent it + // (by design, see items.ts), so nothing ever caught this. KnoTrack + // owns declared order as an application invariant instead: when the + // requested position is occupied, renumber by shifting every item + // at or after it one position later, the same way inserting into + // the middle of an ordered list works, so the new item lands + // exactly where asked without creating a duplicate. Locks the track + // first (like the auto-assign path) since choosing which rows to + // shift is also a read-then-write race between concurrent callers. + await lockTrackForSequenceAssignment(client, track.id); + sequencePosition = input.sequence_position; + await shiftSequencePositionsFrom(client, track.id, sequencePosition); + } else { + await lockTrackForSequenceAssignment(client, track.id); + sequencePosition = (await getMaxSequencePosition(client, track.id)) + 1; + } + + const item = await insertItem(client, { + trackId: track.id, + title: input.title, + sequencePosition, + }); + + await insertItemDependencies(client, item.id, dependsOn); + + return { item_id: item.id }; + }); +} + +export function registerCreateItemTool( + server: McpServer, + pool: Pool, + config: Config, + logger: { error: (obj: unknown, msg?: string) => void }, +): void { + server.registerTool( + 'kt_create_item', + { + title: 'Create item', + description: + 'Creates an Item under a Track, validating same-track depends_on ids and rejecting dependency cycles.', + inputSchema: createItemInputSchema, + }, + async (rawArgs: unknown) => { + const input = createItemInputSchema.parse(rawArgs); + return runTool(logger, 'kt_create_item', () => createItemService(pool, config, input)); + }, + ); +} diff --git a/src/mcp/tools/create-track.ts b/src/mcp/tools/create-track.ts new file mode 100644 index 0000000..d911f02 --- /dev/null +++ b/src/mcp/tools/create-track.ts @@ -0,0 +1,95 @@ +// kt_create_track — docs/TRD.md §3.6. +import type { Pool } from 'pg'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../../config/env.js'; +import { createTrackInputSchema, type CreateTrackInput } from '../../schemas/tools.js'; +import { findActiveProjectById } from '../../db/queries/projects.js'; +import { + getTrackDependencyEdges, + getTrackStatusesForProject, + insertTrack, + insertTrackDependencies, +} from '../../db/queries/tracks.js'; +import { wouldCreateCycle } from '../../domain/dependency-graph.js'; +import { withTransaction } from '../../db/tx.js'; +import { conflict, notFound, validationError } from '../errors.js'; +import { runTool } from '../tool-helpers.js'; + +export interface CreateTrackOutput extends Record { + track_id: string; +} + +export async function createTrackService( + pool: Pool, + _config: Config, + input: CreateTrackInput, +): Promise { + const dependsOn = Array.from(new Set(input.depends_on)); + + return withTransaction(pool, async (client) => { + const project = await findActiveProjectById(client, input.project_id); + if (!project) { + throw notFound('project not found', { project_id: input.project_id }); + } + + const trackStatuses = await getTrackStatusesForProject(client, input.project_id); + const missing = dependsOn.filter((id) => !trackStatuses.has(id)); + if (missing.length > 0) { + throw notFound('one or more depends_on tracks do not exist in this project', { + project_id: input.project_id, + missing_track_ids: missing, + }); + } + + // Cycle check across the project's existing track_dependencies plus + // the proposed new node/edges (TRD §3.6's "systemic invariant"). + const existingEdges = await getTrackDependencyEdges(client, input.project_id); + // The new track doesn't have an id yet; use a sentinel that cannot + // collide with a real UUID, then verify the cycle check below. + const sentinel = '00000000-0000-0000-0000-000000000000'; + if (trackStatuses.has(sentinel)) { + throw validationError('internal sentinel collision — retry'); + } + if (wouldCreateCycle(existingEdges, sentinel, dependsOn)) { + throw conflict('dependency cycle detected', { + project_id: input.project_id, + depends_on: dependsOn, + }); + } + + const allDependenciesDone = dependsOn.every((id) => trackStatuses.get(id) === 'done'); + const status = dependsOn.length === 0 || allDependenciesDone ? 'on_track' : 'blocked'; + + const track = await insertTrack(client, { + projectId: input.project_id, + title: input.title, + status, + sourceDocRef: input.source_doc_ref, + }); + + await insertTrackDependencies(client, track.id, dependsOn); + + return { track_id: track.id }; + }); +} + +export function registerCreateTrackTool( + server: McpServer, + pool: Pool, + config: Config, + logger: { error: (obj: unknown, msg?: string) => void }, +): void { + server.registerTool( + 'kt_create_track', + { + title: 'Create track', + description: + 'Creates a Track under a project, validating depends_on tracks and rejecting dependency cycles.', + inputSchema: createTrackInputSchema, + }, + async (rawArgs: unknown) => { + const input = createTrackInputSchema.parse(rawArgs); + return runTool(logger, 'kt_create_track', () => createTrackService(pool, config, input)); + }, + ); +} diff --git a/src/mcp/tools/get-project-status.ts b/src/mcp/tools/get-project-status.ts new file mode 100644 index 0000000..e7d7ab0 --- /dev/null +++ b/src/mcp/tools/get-project-status.ts @@ -0,0 +1,118 @@ +// kt_get_project_status — docs/TRD.md §3.3. +import type { Pool } from 'pg'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../../config/env.js'; +import { getProjectStatusInputSchema, type GetProjectStatusInput } from '../../schemas/tools.js'; +import { findActiveProjectById } from '../../db/queries/projects.js'; +import { listTracksWithItemCounts } from '../../db/queries/tracks.js'; +import { listOpenDriftFlags } from '../../db/queries/drift-flags.js'; +import { getRecentTimeline } from '../../db/queries/events.js'; +import { withReadSnapshot } from '../../db/tx.js'; +import { notFound } from '../errors.js'; +import { runTool } from '../tool-helpers.js'; + +export interface GetProjectStatusOutput extends Record { + tracks: Array<{ + track_id: string; + title: string; + status: string; + item_counts: { pending: number; in_progress: number; done: number; blocked: number }; + }>; + drift_flags: Array<{ + flag_id: string; + flag_type: string; + severity: string; + track_id: string | null; + item_id: string | null; + detail: string; + status: string; + raised_at: string; + }>; + recent_events: Array<{ + event_id: string; + event_type: string; + track_id: string | null; + summary_text: string; + created_at: string; + }>; +} + +const DRIFT_FLAGS_CAP = 100; +const RECENT_EVENTS_CAP = 20; + +export async function getProjectStatusService( + pool: Pool, + _config: Config, + input: GetProjectStatusInput, +): Promise { + return withReadSnapshot(pool, async (client) => { + const project = await findActiveProjectById(client, input.project_id); + if (!project) { + throw notFound('project not found', { project_id: input.project_id }); + } + + // Sequential, not Promise.all: these now share one PoolClient (a + // single physical connection) rather than three separate pool + // connections, and node-postgres's Client doesn't pipeline concurrent + // .query() calls — issuing them without awaiting in between is + // deprecated (removed in pg@9) even though it happens to still work. + const tracks = await listTracksWithItemCounts(client, input.project_id); + const driftFlags = await listOpenDriftFlags(client, input.project_id, DRIFT_FLAGS_CAP); + const timeline = await getRecentTimeline(client, input.project_id, RECENT_EVENTS_CAP); + + return { + tracks: tracks.map((t) => ({ + track_id: t.id, + title: t.title, + status: t.status, + item_counts: { + pending: t.pending, + in_progress: t.in_progress, + done: t.done, + blocked: t.blocked, + }, + })), + drift_flags: driftFlags.map((f) => ({ + flag_id: f.flag_id, + flag_type: f.flag_type, + severity: f.severity, + track_id: f.track_id, + item_id: f.item_id, + detail: f.detail, + status: f.status, + raised_at: f.raised_at.toISOString(), + })), + recent_events: timeline.map((e) => ({ + event_id: e.event_id, + event_type: e.event_type, + track_id: e.track_id, + summary_text: e.summary_text, + created_at: e.created_at.toISOString(), + })), + }; + }); +} + +export function registerGetProjectStatusTool( + server: McpServer, + pool: Pool, + config: Config, + logger: { error: (obj: unknown, msg?: string) => void }, +): void { + server.registerTool( + 'kt_get_project_status', + { + title: 'Get project status', + description: + 'Roll-up view: tracks with item counts, open drift flags, and recent session-summary/decision events for a project.', + inputSchema: getProjectStatusInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + async (rawArgs: unknown) => { + const input = getProjectStatusInputSchema.parse(rawArgs); + return runTool(logger, 'kt_get_project_status', () => + getProjectStatusService(pool, config, input), + ); + }, + ); +} diff --git a/src/mcp/tools/record-session-summary.ts b/src/mcp/tools/record-session-summary.ts new file mode 100644 index 0000000..fb952ac --- /dev/null +++ b/src/mcp/tools/record-session-summary.ts @@ -0,0 +1,183 @@ +// kt_record_session_summary — docs/TRD.md §3.9. +import type { Pool } from 'pg'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../../config/env.js'; +import { + recordSessionSummaryInputSchema, + type RecordSessionSummaryInput, +} from '../../schemas/tools.js'; +import { findActiveProjectById } from '../../db/queries/projects.js'; +import { findTrackById } from '../../db/queries/tracks.js'; +import { getItemsByIds, listItemsByTrack } from '../../db/queries/items.js'; +import { insertEvent } from '../../db/queries/events.js'; +import { + insertDriftFlagIfNotOpen, + listOpenFlagsForTrack, + resolveDriftFlags, + driftFlagToView, +} from '../../db/queries/drift-flags.js'; +import { findSequenceSkips } from '../../domain/drift-detector.js'; +import { withTransaction } from '../../db/tx.js'; +import { notFound, validationError } from '../errors.js'; +import { runTool } from '../tool-helpers.js'; + +export interface RecordSessionSummaryOutput extends Record { + event_id: string; + drift_flags_raised: Array<{ + flag_id: string; + flag_type: string; + severity: string; + detail: string; + }>; +} + +export async function recordSessionSummaryService( + pool: Pool, + config: Config, + input: RecordSessionSummaryInput, + // Defaulting to `console` keeps every existing call site working + // unchanged while registerRecordSessionSummaryTool passes the real + // request logger (used below to note a skipped drift scan). + logger: { error: (obj: unknown, msg?: string) => void } = console, +): Promise { + const itemsTouched = Array.from(new Set(input.items_touched)); + + return withTransaction(pool, async (client) => { + const project = await findActiveProjectById(client, input.project_id); + if (!project) { + throw notFound('project not found', { project_id: input.project_id }); + } + + const track = await findTrackById(client, input.project_id, input.track_id); + if (!track) { + throw notFound('track not found in this project', { + project_id: input.project_id, + track_id: input.track_id, + }); + } + + if (itemsTouched.length > 0) { + const foundItems = await getItemsByIds(client, itemsTouched); + const missing = itemsTouched.filter((id) => !foundItems.has(id)); + if (missing.length > 0) { + throw notFound('one or more items_touched ids do not exist as items at all', { + missing_item_ids: missing, + }); + } + const wrongTrack = itemsTouched.filter((id) => foundItems.get(id)?.track_id !== track.id); + if (wrongTrack.length > 0) { + throw validationError('items_touched ids must belong to track_id', { + wrong_track_item_ids: wrongTrack, + track_id: track.id, + }); + } + } + + const event = await insertEvent(client, { + projectId: input.project_id, + trackId: track.id, + summaryText: input.summary_text, + filesTouched: input.files_touched, + itemsTouched, + }); + + // Scoped drift re-check (TRD §3.9): this track only, not the whole + // project. See src/domain/drift-detector.ts's header comment for why + // only the 'out_of_sequence' kind is checked here. + // + // adversarial-review reliability-4 / P1: findSequenceSkips is O(n^2) + // in the track's item count (a nested scan for the earliest + // incomplete item per done item). KNOTRACK_DRIFT_SCAN_ITEM_CAP bounds + // this — but it's documented (TRD §6.3/§7) as a limit on + // kt_check_drift's scan, not a reason to refuse an otherwise-valid + // kt_record_session_summary write. A prior fix enforced it by + // throwing and rolling back the whole call (including the event + // insert above) once a track outgrew the cap, which meant a track's + // session summaries silently stopped being recordable at all past + // that size. The re-check is a best-effort side effect of this write, + // not its purpose: past the cap, skip the scan (log it) and still let + // the event commit. + const trackItems = await listItemsByTrack(client, track.id); + const raised: RecordSessionSummaryOutput['drift_flags_raised'] = []; + if (trackItems.length > config.driftScanItemCap) { + logger.error( + { track_id: track.id, item_count: trackItems.length, cap: config.driftScanItemCap }, + 'kt_record_session_summary: track exceeds driftScanItemCap — skipping the scoped drift re-check for this call, event still recorded', + ); + } else { + const findings = findSequenceSkips(trackItems); + const findingItemIds = new Set(findings.map((f) => f.itemId)); + + // adversarial-review P1 (resolve cleared flags): reconcile this + // track's existing open flags against the current findings first — + // any item that had an open flag but no longer matches a finding + // (e.g. the earlier item it was blocked on is now also done) has + // had its condition clear, so its flag gets resolved_at set. Read + // before raising new ones below so an item's open-ness reflects + // this scan, not a stale one. + const openFlags = await listOpenFlagsForTrack(client, track.id, 'out_of_sequence'); + const openItemIds = new Set( + openFlags.map((f) => f.item_id).filter((id): id is string => id !== null), + ); + const toResolve = openFlags + .filter((f) => f.item_id !== null && !findingItemIds.has(f.item_id)) + .map((f) => f.id); + if (toResolve.length > 0) { + await resolveDriftFlags(client, toResolve); + } + + // adversarial-review P1 (serialize open-flag creation): insertion + // is an atomic INSERT ... ON CONFLICT DO NOTHING against the DB's + // uq_drift_flags_open_item_kind partial unique index + // (migrations/003), not a separate check-then-insert — two + // concurrent calls raising a flag for the same item can no longer + // both succeed. + for (const finding of findings) { + if (openItemIds.has(finding.itemId)) continue; + const flagRow = await insertDriftFlagIfNotOpen(client, { + projectId: input.project_id, + trackId: track.id, + itemId: finding.itemId, + kind: 'out_of_sequence', + detail: { message: finding.detail }, + }); + if (!flagRow) continue; + const view = driftFlagToView(flagRow); + raised.push({ + flag_id: view.flag_id, + flag_type: view.flag_type, + severity: view.severity, + detail: view.detail, + }); + } + } + + return { + event_id: event.id, + drift_flags_raised: raised, + }; + }); +} + +export function registerRecordSessionSummaryTool( + server: McpServer, + pool: Pool, + config: Config, + logger: { error: (obj: unknown, msg?: string) => void }, +): void { + server.registerTool( + 'kt_record_session_summary', + { + title: 'Record session summary', + description: + "Appends a session's summary plus files/items touched to a track's event log, then re-runs the scoped drift re-check for that track.", + inputSchema: recordSessionSummaryInputSchema, + }, + async (rawArgs: unknown) => { + const input = recordSessionSummaryInputSchema.parse(rawArgs); + return runTool(logger, 'kt_record_session_summary', () => + recordSessionSummaryService(pool, config, input, logger), + ); + }, + ); +} diff --git a/src/mcp/tools/register-project.ts b/src/mcp/tools/register-project.ts new file mode 100644 index 0000000..a7abb8e --- /dev/null +++ b/src/mcp/tools/register-project.ts @@ -0,0 +1,113 @@ +// kt_register_project — docs/TRD.md §3.2. +import type { Pool } from 'pg'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../../config/env.js'; +import { registerProjectInputSchema, type RegisterProjectInput } from '../../schemas/tools.js'; +import { upsertProjectBySourceRef } from '../../db/queries/projects.js'; +import { upsertAdapter } from '../../db/queries/adapters.js'; +import { encryptCredential } from '../../crypto/credential-cipher.js'; +import { withTransaction } from '../../db/tx.js'; +import { internalError } from '../errors.js'; +import { runTool } from '../tool-helpers.js'; + +export interface RegisterProjectOutput extends Record { + project_id: string; +} + +/** + * Upsert semantics (TRD §3.2): uniqueness is on (source_type, source_ref). + * Calling again with the same pair updates name/adapters on the existing + * row and returns the *original* project_id — never a duplicate, never a + * 409. + */ +export async function registerProjectService( + pool: Pool, + config: Config, + input: RegisterProjectInput, + // adversarial-review P2: the catch blocks below used to put the raw + // driver-error message on `details.cause`, which runTool then serializes + // verbatim into the client-facing envelope — leaking DB/driver internals + // through what TRD §3.1 documents as a generic 500. Defaulting to + // `console` keeps every existing call site working unchanged while + // registerProjectTool passes the real request logger. + logger: { error: (obj: unknown, msg?: string) => void } = console, +): Promise { + return withTransaction(pool, async (client) => { + // Atomic upsert on (source_type, source_ref) — see + // upsertProjectBySourceRef's doc comment (adversarial-review + // correctness-3: this used to be a separate find-then-insert, racy + // under concurrent calls with the same source identity). + const project = await upsertProjectBySourceRef(client, { + name: input.name, + sourceType: input.source_type, + sourceRef: input.source_ref, + }); + const projectId = project.id; + + if (input.adapters?.github) { + try { + const encrypted = encryptCredential( + input.adapters.github.personal_access_token, + config.encryptionKey, + ); + await upsertAdapter(client, { + projectId, + type: 'github', + encryptedCredential: encrypted, + config: { + repo: + input.adapters.github.repo ?? + (input.source_type === 'github' ? input.source_ref : undefined), + connected: true, + }, + }); + } catch (cause) { + logger.error({ err: cause }, 'failed to store github adapter credential'); + throw internalError('failed to store github adapter credential'); + } + } + + if (input.adapters?.linear) { + try { + const encrypted = encryptCredential(input.adapters.linear.api_key, config.encryptionKey); + await upsertAdapter(client, { + projectId, + type: 'linear', + encryptedCredential: encrypted, + config: { + team_id: input.adapters.linear.team_id, + connected: true, + }, + }); + } catch (cause) { + logger.error({ err: cause }, 'failed to store linear adapter credential'); + throw internalError('failed to store linear adapter credential'); + } + } + + return { project_id: projectId }; + }); +} + +export function registerProjectTool( + server: McpServer, + pool: Pool, + config: Config, + logger: { error: (obj: unknown, msg?: string) => void }, +): void { + server.registerTool( + 'kt_register_project', + { + title: 'Register project', + description: + 'Registers a project, or upserts one on (source_type, source_ref). This is the only mechanism to add/rotate adapter credentials after initial registration.', + inputSchema: registerProjectInputSchema, + }, + async (rawArgs: unknown) => { + const input = registerProjectInputSchema.parse(rawArgs); + return runTool(logger, 'kt_register_project', () => + registerProjectService(pool, config, input, logger), + ); + }, + ); +} diff --git a/src/mcp/tools/stubs.ts b/src/mcp/tools/stubs.ts new file mode 100644 index 0000000..a8c357f --- /dev/null +++ b/src/mcp/tools/stubs.ts @@ -0,0 +1,108 @@ +// Stub registrations for the 9 tools out of scope for this build. Each is +// registered with its real, TRD-accurate input schema (so `tools/list` +// reflects the full 14-tool surface and clients can still see the exact +// contract) but the handler always returns a clear "not yet implemented" +// isError result rather than doing any work. +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + listTracksInputSchema, + getTrackInputSchema, + getNextStepsInputSchema, + recordDecisionInputSchema, + updateItemStatusInputSchema, + checkDriftInputSchema, + renderRoadmapInputSchema, + syncToGithubInputSchema, + syncToLinearInputSchema, +} from '../../schemas/tools.js'; +import { notImplementedResult } from '../tool-helpers.js'; +import type { ZodTypeAny } from 'zod'; + +interface StubSpec { + name: string; + title: string; + description: string; + inputSchema: ZodTypeAny; + annotations?: Record; +} + +const STUBS: StubSpec[] = [ + { + name: 'kt_list_tracks', + title: 'List tracks', + description: "List a project's tracks, optionally filtered by status.", + inputSchema: listTracksInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + { + name: 'kt_get_track', + title: 'Get track', + description: 'Track detail: items plus dependency graph.', + inputSchema: getTrackInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + { + name: 'kt_get_next_steps', + title: 'Get next steps', + description: + 'Advisory-only ranked list of unblocked items. Never assigns, claims, or locks anything.', + inputSchema: getNextStepsInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + { + name: 'kt_record_decision', + title: 'Record decision', + description: + "Logs an explicit pivot/decision against a track; sets that track's stored status to pivot_pending.", + inputSchema: recordDecisionInputSchema, + }, + { + name: 'kt_update_item_status', + title: 'Update item status', + description: + "Changes an item's status; transitioning to done requires all its dependencies to already be done.", + inputSchema: updateItemStatusInputSchema, + }, + { + name: 'kt_check_drift', + title: 'Check drift', + description: 'Full, project-wide, synchronous drift scan.', + inputSchema: checkDriftInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + { + name: 'kt_render_roadmap', + title: 'Render roadmap', + description: 'Generates a roadmap document (markdown or mermaid) from current DB state.', + inputSchema: renderRoadmapInputSchema, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, + { + name: 'kt_sync_to_github', + title: 'Sync to GitHub', + description: 'Pushes a track/item to a linked GitHub Issue.', + inputSchema: syncToGithubInputSchema, + }, + { + name: 'kt_sync_to_linear', + title: 'Sync to Linear', + description: 'Pushes a track/item to a linked Linear Issue.', + inputSchema: syncToLinearInputSchema, + }, +]; + +export function registerStubTools(server: McpServer): void { + for (const stub of STUBS) { + server.registerTool( + stub.name, + { + title: stub.title, + description: `${stub.description} [NOT YET IMPLEMENTED in this build]`, + inputSchema: stub.inputSchema, + ...(stub.annotations ? { annotations: stub.annotations } : {}), + }, + // eslint-disable-next-line @typescript-eslint/require-await + async () => notImplementedResult(stub.name), + ); + } +} diff --git a/src/schemas/tools.ts b/src/schemas/tools.ts new file mode 100644 index 0000000..ad0acb7 --- /dev/null +++ b/src/schemas/tools.ts @@ -0,0 +1,151 @@ +// One zod schema per tool — single source of truth (TRD §3.0). Each is a +// `.strict()` ZodObject so the MCP SDK's tools/list JSON Schema output +// carries "additionalProperties": false, and so runtime parsing rejects +// any field not listed, per TRD §3.0's closed-schema rule. +import { z } from 'zod'; + +const uuid = () => z.string().uuid(); + +const githubAdapterInput = z + .object({ + personal_access_token: z.string().min(1), + repo: z.string().min(1).optional(), + }) + .strict(); + +const linearAdapterInput = z + .object({ + api_key: z.string().min(1), + team_id: z.string().min(1), + }) + .strict(); + +export const registerProjectInputSchema = z + .object({ + name: z.string().min(1).max(200), + source_type: z.enum(['github', 'linear', 'local']), + source_ref: z.string().min(1).max(500), + adapters: z + .object({ + github: githubAdapterInput.optional(), + linear: linearAdapterInput.optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export const getProjectStatusInputSchema = z + .object({ + project_id: uuid(), + }) + .strict(); + +export const listTracksInputSchema = z + .object({ + project_id: uuid(), + status: z.enum(['on_track', 'pivot_pending', 'blocked', 'done']).optional(), + }) + .strict(); + +export const getTrackInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + }) + .strict(); + +export const getNextStepsInputSchema = z + .object({ + project_id: uuid(), + }) + .strict(); + +export const createTrackInputSchema = z + .object({ + project_id: uuid(), + title: z.string().min(1).max(300), + depends_on: z.array(uuid()).max(50).default([]), + source_doc_ref: z.string().max(500).optional(), + }) + .strict(); + +export const createItemInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + title: z.string().min(1).max(300), + sequence_position: z.number().int().min(0).optional(), + depends_on: z.array(uuid()).max(100).default([]), + }) + .strict(); + +export const recordSessionSummaryInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + summary_text: z.string().min(1).max(10000), + files_touched: z.array(z.string().min(1).max(1000)).default([]), + items_touched: z.array(uuid()).default([]), + }) + .strict(); + +export const recordDecisionInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + title: z.string().min(1).max(300), + rationale: z.string().min(1).max(5000), + what_changed: z.string().min(1).max(5000), + }) + .strict(); + +export const updateItemStatusInputSchema = z + .object({ + project_id: uuid(), + item_id: uuid(), + status: z.enum(['pending', 'in_progress', 'done', 'blocked']), + }) + .strict(); + +export const checkDriftInputSchema = z + .object({ + project_id: uuid(), + }) + .strict(); + +export const renderRoadmapInputSchema = z + .object({ + project_id: uuid(), + format: z.enum(['markdown', 'mermaid']).default('markdown'), + }) + .strict(); + +export const syncToGithubInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + }) + .strict(); + +export const syncToLinearInputSchema = z + .object({ + project_id: uuid(), + track_id: uuid(), + }) + .strict(); + +export type RegisterProjectInput = z.infer; +export type GetProjectStatusInput = z.infer; +export type ListTracksInput = z.infer; +export type GetTrackInput = z.infer; +export type GetNextStepsInput = z.infer; +export type CreateTrackInput = z.infer; +export type CreateItemInput = z.infer; +export type RecordSessionSummaryInput = z.infer; +export type RecordDecisionInput = z.infer; +export type UpdateItemStatusInput = z.infer; +export type CheckDriftInput = z.infer; +export type RenderRoadmapInput = z.infer; +export type SyncToGithubInput = z.infer; +export type SyncToLinearInput = z.infer; diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..c94fbcd --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,59 @@ +// Bearer-token preHandler hook — docs/TRD.md §4. Registered on POST /mcp +// only; never on GET /health or GET /info. +import crypto from 'node:crypto'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { KtErrorEnvelope } from '../mcp/errors.js'; + +function sha256(input: string): Buffer { + return crypto.createHash('sha256').update(input, 'utf8').digest(); +} + +/** Constant-time membership check across the whole token pool — every + * configured token is compared (not short-circuited) so unauthorized + * request timing doesn't leak "which token almost matched". */ +export function isAuthorizedToken(presented: string, configured: string[]): boolean { + const presentedHash = sha256(presented); + let authorized = false; + for (const token of configured) { + const tokenHash = sha256(token); + if (crypto.timingSafeEqual(presentedHash, tokenHash)) { + authorized = true; + } + } + return authorized; +} + +const UNAUTHORIZED_ENVELOPE: KtErrorEnvelope = { + error: { + code: 'UNAUTHORIZED', + http_status_equivalent: 401, + message: 'missing or invalid bearer token', + }, +}; + +export function createAuthPreHandler(apiTokens: string[]) { + return function authPreHandler( + request: FastifyRequest, + reply: FastifyReply, + done: (err?: Error) => void, + ): void { + const header = request.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + reply.code(401).send(UNAUTHORIZED_ENVELOPE); + return; + } + // Exactly "Bearer " — a single space, case-sensitive scheme. + const spaceIndex = header.indexOf(' '); + const scheme = header.slice(0, spaceIndex); + const token = header.slice(spaceIndex + 1); + if (scheme !== 'Bearer' || token.length === 0 || token.includes(' ')) { + reply.code(401).send(UNAUTHORIZED_ENVELOPE); + return; + } + if (!isAuthorizedToken(token, apiTokens)) { + reply.code(401).send(UNAUTHORIZED_ENVELOPE); + return; + } + done(); + }; +} diff --git a/src/server/fastify.ts b/src/server/fastify.ts new file mode 100644 index 0000000..06aaec6 --- /dev/null +++ b/src/server/fastify.ts @@ -0,0 +1,17 @@ +// Builds the Fastify instance and registers routes/hooks. +import Fastify, { type FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import type { Config } from '../config/env.js'; +import { registerHealthRoutes } from './health-route.js'; +import { registerMcpRoute } from './mcp-route.js'; + +export function buildFastify(pool: Pool, config: Config, instanceStartedAt: Date): FastifyInstance { + const app = Fastify({ + logger: { level: config.logLevel }, + }); + + registerHealthRoutes(app, config, instanceStartedAt); + registerMcpRoute(app, pool, config); + + return app; +} diff --git a/src/server/health-route.ts b/src/server/health-route.ts new file mode 100644 index 0000000..62728f0 --- /dev/null +++ b/src/server/health-route.ts @@ -0,0 +1,87 @@ +// GET /health and GET /info — docs/TRD.md §8. Both plain, unauthenticated +// Fastify routes, not MCP tools. +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import type { Config } from '../config/env.js'; +import { createPool } from '../db/pool.js'; + +const SERVER_VERSION = '0.1.0'; +const MCP_PROTOCOL_VERSION = '2026-07-28'; +const SUPPORTED_ADAPTERS = ['github', 'linear']; + +// How long a single /health ping is allowed to run before Postgres itself +// cancels it. adversarial-review: this used to be a client-side +// `Promise.race` against a `setTimeout` — that only stopped *this request* +// from waiting on `pool.query('SELECT 1')`, it never actually canceled the +// query. A slow/unreachable DB left it running to completion (or to the +// pool's own, much larger `statement_timeout`) on one of the health pool's +// 2 connections regardless, so repeated timed-out requests could tie up +// both connections and pile up pending acquisitions behind them. Setting +// `statement_timeout` on the health pool itself makes Postgres cancel the +// query server-side at this bound, which frees the connection back to the +// pool for the next check instead of just abandoning it client-side. +export const HEALTH_CHECK_STATEMENT_TIMEOUT_MS = 1000; + +async function pingDb(pool: Pool): Promise { + try { + await pool.query('SELECT 1'); + return true; + } catch { + return false; + } +} + +export function registerHealthRoutes( + app: FastifyInstance, + config: Config, + instanceStartedAt: Date, +): void { + // adversarial-review security-1: /health is unauthenticated by design + // (docs/TRD.md §8) but was pinging the same pool the authenticated MCP + // tool traffic depends on. A flood of unauthenticated /health requests + // (or a slow DB making pingDb's queries queue past the 1s race timeout) + // could hold connections/queue slots on that shared pool, starving real + // MCP calls. A small, dedicated pool isolates that blast radius: /health + // can only ever contend with itself, never with tool traffic. Closed via + // Fastify's onClose hook so it doesn't outlive the server. + const healthPool = createPool(config, { + max: 2, + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 10000, + statement_timeout: HEALTH_CHECK_STATEMENT_TIMEOUT_MS, + }); + app.addHook('onClose', async () => { + await healthPool.end(); + }); + + app.get('/health', async (_request, reply) => { + const dbOk = await pingDb(healthPool); + const uptimeSeconds = Math.floor((Date.now() - instanceStartedAt.getTime()) / 1000); + if (dbOk) { + return reply.code(200).send({ + status: 'ok', + version: SERVER_VERSION, + mcp_protocol_version: MCP_PROTOCOL_VERSION, + uptime_seconds: uptimeSeconds, + db: 'ok', + }); + } + return reply.code(503).send({ + status: 'error', + version: SERVER_VERSION, + uptime_seconds: uptimeSeconds, + db: 'error', + error: 'db_unreachable', + }); + }); + + app.get('/info', async (_request, reply) => { + return reply.code(200).send({ + server_version: SERVER_VERSION, + mcp_protocol_version: MCP_PROTOCOL_VERSION, + node_version: process.version, + supported_adapters: SUPPORTED_ADAPTERS, + instance_started_at: instanceStartedAt.toISOString(), + }); + }); +} diff --git a/src/server/mcp-route.ts b/src/server/mcp-route.ts new file mode 100644 index 0000000..75533fb --- /dev/null +++ b/src/server/mcp-route.ts @@ -0,0 +1,75 @@ +// Mounts POST /mcp using StreamableHTTPServerTransport in stateless mode +// (docs/TRD.md §0 / §1 — MCP protocol revision 2026-07-28 has no +// initialize/initialized handshake and no Mcp-Session-Id). +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { FastifyInstance } from 'fastify'; +import type { Pool } from 'pg'; +import type { Config } from '../config/env.js'; +import { buildMcpServer, type Logger } from '../mcp/server.js'; +import { createAuthPreHandler } from './auth.js'; + +export function registerMcpRoute(app: FastifyInstance, pool: Pool, config: Config): void { + const logger: Logger = { + error: (obj, msg) => app.log.error(obj, msg), + }; + + app.post( + '/mcp', + // adversarial-review security-2: Fastify's lifecycle runs preParsing/ + // body-parsing BEFORE preHandler, so an auth check registered as + // preHandler still pays the cost of parsing an unauthenticated + // request's JSON body (up to the default 1MiB bodyLimit) before + // rejecting it. onRequest runs first, before the body is read at all, + // so an invalid/missing bearer token is rejected without ever parsing + // the payload. createAuthPreHandler only reads request.headers, so it + // has no dependency on the parsed body and is safe to run this early. + { onRequest: createAuthPreHandler(config.apiTokens) }, + async (request, reply) => { + // A fresh McpServer + transport per request keeps this genuinely + // stateless: no session state, no shared in-flight request-id + // bookkeeping across concurrent calls. Tool registration itself is + // cheap (just populates lookup tables against the shared pool/config + // singletons), so this has no real per-request cost. + const server = buildMcpServer(pool, config, logger); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + + reply.raw.on('close', () => { + transport.close().catch(() => undefined); + server.close().catch(() => undefined); + }); + + // transport.handleRequest writes directly to the raw response, so + // Fastify must be told to stop owning this reply *before* that call + // runs — per Fastify's documented hijack contract, calling hijack() + // only after an awaited handler settles is too late: a rejection + // from handleRequest (e.g. thrown before it writes anything) would + // otherwise leave Fastify still expecting to send its own response + // on a reply that may already have had raw bytes written to it. + reply.hijack(); + try { + await server.connect(transport); + await transport.handleRequest(request.raw, reply.raw, request.body); + } catch (error) { + app.log.error({ err: error }, 'unhandled error in /mcp request handling'); + if (!reply.raw.headersSent && !reply.raw.writableEnded) { + reply.raw.writeHead(500, { 'content-type': 'application/json' }); + reply.raw.end( + JSON.stringify({ + error: { + code: 'INTERNAL_ERROR', + http_status_equivalent: 500, + message: 'an unexpected error occurred', + }, + }), + ); + } else if (!reply.raw.writableEnded) { + // Headers (e.g. an in-progress SSE stream) were already sent + // before handleRequest rejected, so a fresh JSON error body can't + // be written — but the response still has to be terminated, or + // the client hangs on an open connection until its own timeout. + reply.raw.end(); + } + } + }, + ); +} diff --git a/stryker.conf.json b/stryker.conf.json new file mode 100644 index 0000000..94389a8 --- /dev/null +++ b/stryker.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "packageManager": "npm", + "testRunner": "vitest", + "vitest": { + "configFile": "vitest.stryker.config.ts" + }, + "mutate": [ + "src/domain/dependency-graph.ts", + "src/domain/drift-detector.ts", + "src/crypto/credential-cipher.ts", + "src/server/auth.ts" + ], + "reporters": ["clear-text", "json"], + "jsonReporter": { + "fileName": "stryker-report.json" + }, + "thresholds": { + "high": 80, + "low": 60, + "break": 60 + }, + "concurrency": 2 +} diff --git a/tests/integration/create-item.test.ts b/tests/integration/create-item.test.ts new file mode 100644 index 0000000..bb65395 --- /dev/null +++ b/tests/integration/create-item.test.ts @@ -0,0 +1,311 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { createItemService } from '../../src/mcp/tools/create-item.js'; +import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { lockTrackForSequenceAssignment } from '../../src/db/queries/items.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll, UNKNOWN_UUID } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); + +async function makeProjectAndTrack(): Promise<{ projectId: string; trackId: string }> { + const { project_id } = await registerProjectService(pool, config, { + name: 'P', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + const { track_id } = await createTrackService(pool, config, { + project_id, + title: 'T', + depends_on: [], + source_doc_ref: undefined, + }); + return { projectId: project_id, trackId: track_id }; +} + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('kt_create_item', () => { + it('positive: creates an item with auto-assigned sequence_position starting at 1', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const first = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'First item', + sequence_position: undefined, + depends_on: [], + }); + const second = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Second item', + sequence_position: undefined, + depends_on: [], + }); + + const rows = await pool.query( + 'SELECT id, sequence_position FROM items WHERE track_id = $1 ORDER BY sequence_position', + [trackId], + ); + expect(rows.rows).toEqual([ + { id: first.item_id, sequence_position: 1 }, + { id: second.item_id, sequence_position: 2 }, + ]); + }); + + it('positive: accepts a same-track depends_on list', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const base = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Base', + sequence_position: undefined, + depends_on: [], + }); + const dependent = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Dependent', + sequence_position: undefined, + depends_on: [base.item_id], + }); + const edge = await pool.query( + 'SELECT * FROM item_dependencies WHERE item_id = $1 AND depends_on_item_id = $2', + [dependent.item_id, base.item_id], + ); + expect(edge.rowCount).toBe(1); + }); + + // adversarial-review P1: an explicit sequence_position already occupied + // by another item used to be inserted unchanged, producing a duplicate + // declared position instead of the documented application-owned + // ordering. The fix renumbers by shifting everything at or after the + // requested position one later, matching how inserting into the middle + // of an ordered list works. + it('positive: an occupied explicit sequence_position shifts subsequent items instead of colliding', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const a = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'A', + sequence_position: 1, + depends_on: [], + }); + const b = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'B', + sequence_position: 2, + depends_on: [], + }); + const c = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'C (inserted at 1)', + sequence_position: 1, + depends_on: [], + }); + + const rows = await pool.query<{ id: string; sequence_position: number }>( + 'SELECT id, sequence_position FROM items WHERE track_id = $1 ORDER BY sequence_position', + [trackId], + ); + expect(rows.rows).toEqual([ + { id: c.item_id, sequence_position: 1 }, + { id: a.item_id, sequence_position: 2 }, + { id: b.item_id, sequence_position: 3 }, + ]); + }); + + it('negative: 404 when track does not exist in project', async () => { + const { projectId } = await makeProjectAndTrack(); + await expect( + createItemService(pool, config, { + project_id: projectId, + track_id: UNKNOWN_UUID, + title: 'X', + sequence_position: undefined, + depends_on: [], + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('negative: 404 when a depends_on id does not exist as an item at all', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + await expect( + createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'X', + sequence_position: undefined, + depends_on: [UNKNOWN_UUID], + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('negative: 422 (VALIDATION_ERROR) when a depends_on item exists but belongs to a different track', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const otherTrack = await createTrackService(pool, config, { + project_id: projectId, + title: 'Other track', + depends_on: [], + source_doc_ref: undefined, + }); + const itemInOtherTrack = await createItemService(pool, config, { + project_id: projectId, + track_id: otherTrack.track_id, + title: 'Lives elsewhere', + sequence_position: undefined, + depends_on: [], + }); + + await expect( + createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'X', + sequence_position: undefined, + depends_on: [itemInOtherTrack.item_id], + }), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); + }); + + // adversarial-review test_quality-1: the service-layer wiring of the + // item-level cycle check (existingEdges fetched scoped to the right + // track, the sentinel used correctly, the 409 actually thrown) had no + // integration test — only the pure wouldCreateCycle() function was unit + // tested. Mirrors the equivalent test in create-track.test.ts. + it('negative: 409 dependency cycle — pre-existing cyclic item_dependencies data is rejected defensively', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const a = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'A', + sequence_position: undefined, + depends_on: [], + }); + const b = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'B', + sequence_position: undefined, + depends_on: [], + }); + // Force a genuine cycle in stored data by inserting both directions + // directly, bypassing the service (which would itself reject the + // second edge — this simulates data that predates the invariant). + await pool.query( + 'INSERT INTO item_dependencies (item_id, depends_on_item_id) VALUES ($1, $2)', + [a.item_id, b.item_id], + ); + await pool.query( + 'INSERT INTO item_dependencies (item_id, depends_on_item_id) VALUES ($1, $2)', + [b.item_id, a.item_id], + ); + + await expect( + createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'C (unrelated)', + sequence_position: undefined, + depends_on: [], + }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); + + // adversarial-review correctness-1: getMaxSequencePosition() followed by + // insertItem() was a read-then-write with no lock and no unique + // constraint — two concurrent kt_create_item calls on the same track + // could both read the same MAX and insert the same sequence_position. + // Deterministic test of the actual mechanism the fix relies on: a + // SELECT ... FOR UPDATE on the track row must block a second + // transaction until the first commits (this is a hard Postgres + // guarantee, not a timing race — the assertion holds every run). + it('negative: lockTrackForSequenceAssignment serializes concurrent holders on the same track', async () => { + const { trackId } = await makeProjectAndTrack(); + const clientA = await pool.connect(); + const clientB = await pool.connect(); + // Declared here (not inside the try block) so the finally block can + // always settle it, even when an assertion above throws before the + // normal `await bLock` is reached — leaving it unawaited would let a + // still-pending (or later-rejecting) lock acquisition on clientB + // become an unhandled rejection once the test has already moved on. + let bLock: Promise | undefined; + try { + await clientA.query('BEGIN'); + await clientB.query('BEGIN'); + + await lockTrackForSequenceAssignment(clientA, trackId); + + let bAcquired = false; + bLock = lockTrackForSequenceAssignment(clientB, trackId).then(() => { + bAcquired = true; + }); + + // B must still be blocked while A holds the lock. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(bAcquired).toBe(false); + + await clientA.query('COMMIT'); + await bLock; + expect(bAcquired).toBe(true); + + await clientB.query('COMMIT'); + } finally { + // `pg` does not auto-rollback a transaction on release() — if an + // assertion above threw before the COMMITs, both clients would + // otherwise go back to the pool with an open transaction still on + // them, leaking into whichever test acquires that connection next. + // ROLLBACK outside a transaction (i.e. after a successful COMMIT + // already closed it) is a harmless Postgres no-op, safe either way. + await clientA.query('ROLLBACK').catch(() => undefined); + // Rolling back A first releases the row lock clientB's query may + // still be blocked on, so awaiting bLock here (rather than leaving + // it dangling) lets that pending acquisition settle — successfully + // or not — before clientB is released back to the pool. + if (bLock) await bLock.catch(() => undefined); + await clientB.query('ROLLBACK').catch(() => undefined); + clientA.release(); + clientB.release(); + } + }); + + // Functional check that the real wiring in createItemService produces + // distinct sequence_position values under concurrent auto-assigns, not + // just that the lock primitive itself blocks. With the fix, every + // sequence_position is guaranteed distinct (the lock forces the + // reads-then-writes to serialize); this is deterministic given the fix, + // even though a run without the fix would only be very likely — not + // logically guaranteed — to surface a duplicate. + it('positive: concurrent auto-assigned creates on the same track never collide', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const results = await Promise.all( + Array.from({ length: 8 }, (_, i) => + createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: `Concurrent ${i}`, + sequence_position: undefined, + depends_on: [], + }), + ), + ); + const rows = await pool.query<{ sequence_position: number }>( + 'SELECT sequence_position FROM items WHERE track_id = $1', + [trackId], + ); + expect(rows.rows).toHaveLength(8); + const positions = rows.rows.map((r) => r.sequence_position); + expect(new Set(positions).size).toBe(8); + expect([...positions].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + void results; + }); +}); diff --git a/tests/integration/create-track.test.ts b/tests/integration/create-track.test.ts new file mode 100644 index 0000000..9023d8a --- /dev/null +++ b/tests/integration/create-track.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { KtError } from '../../src/mcp/errors.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll, UNKNOWN_UUID } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); + +async function makeProject(): Promise { + const { project_id } = await registerProjectService(pool, config, { + name: 'P', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + return project_id; +} + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('kt_create_track', () => { + it('positive: no depends_on -> status on_track', async () => { + const projectId = await makeProject(); + const result = await createTrackService(pool, config, { + project_id: projectId, + title: 'Auth overhaul', + depends_on: [], + source_doc_ref: undefined, + }); + const row = await pool.query('SELECT status FROM tracks WHERE id = $1', [result.track_id]); + expect(row.rows[0].status).toBe('on_track'); + }); + + it('positive: depends_on an unfinished track -> status blocked', async () => { + const projectId = await makeProject(); + const prereq = await createTrackService(pool, config, { + project_id: projectId, + title: 'Prereq (not done)', + depends_on: [], + source_doc_ref: undefined, + }); + const result = await createTrackService(pool, config, { + project_id: projectId, + title: 'Depends on prereq', + depends_on: [prereq.track_id], + source_doc_ref: undefined, + }); + const row = await pool.query('SELECT status FROM tracks WHERE id = $1', [result.track_id]); + expect(row.rows[0].status).toBe('blocked'); + }); + + it('positive: depends_on an already-done track -> status on_track', async () => { + const projectId = await makeProject(); + const prereq = await createTrackService(pool, config, { + project_id: projectId, + title: 'Prereq (done)', + depends_on: [], + source_doc_ref: undefined, + }); + await pool.query(`UPDATE tracks SET status = 'done' WHERE id = $1`, [prereq.track_id]); + + const result = await createTrackService(pool, config, { + project_id: projectId, + title: 'Depends on done prereq', + depends_on: [prereq.track_id], + source_doc_ref: undefined, + }); + const row = await pool.query('SELECT status FROM tracks WHERE id = $1', [result.track_id]); + expect(row.rows[0].status).toBe('on_track'); + }); + + it('negative: 404 when project does not exist', async () => { + await expect( + createTrackService(pool, config, { + project_id: UNKNOWN_UUID, + title: 'X', + depends_on: [], + source_doc_ref: undefined, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' } satisfies Partial); + }); + + it('negative: 404 when a depends_on track id does not exist in the project', async () => { + const projectId = await makeProject(); + await expect( + createTrackService(pool, config, { + project_id: projectId, + title: 'X', + depends_on: [UNKNOWN_UUID], + source_doc_ref: undefined, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('negative: 409 dependency cycle — pre-existing cyclic track_dependencies data is rejected defensively', async () => { + // TRD §3.6 notes that with the v1 tool set alone, a cycle can never + // arise through legitimate kt_create_track calls (a brand-new track + // can only point at already-existing tracks, never the reverse) — the + // check exists to fail safe against data that predates the invariant + // or was written directly. We simulate that here by seeding a cyclic + // track_dependencies pair directly, bypassing the service. + const projectId = await makeProject(); + const a = await createTrackService(pool, config, { + project_id: projectId, + title: 'A', + depends_on: [], + source_doc_ref: undefined, + }); + const b = await createTrackService(pool, config, { + project_id: projectId, + title: 'B', + depends_on: [], + source_doc_ref: undefined, + }); + // A -> B already exists structurally as a legitimate edge; force B -> A + // directly to create a genuine cycle in stored data. + await pool.query( + 'INSERT INTO track_dependencies (track_id, depends_on_track_id) VALUES ($1, $2)', + [a.track_id, b.track_id], + ); + await pool.query( + 'INSERT INTO track_dependencies (track_id, depends_on_track_id) VALUES ($1, $2)', + [b.track_id, a.track_id], + ); + + await expect( + createTrackService(pool, config, { + project_id: projectId, + title: 'C (unrelated)', + depends_on: [], + source_doc_ref: undefined, + }), + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); +}); diff --git a/tests/integration/get-project-status.test.ts b/tests/integration/get-project-status.test.ts new file mode 100644 index 0000000..924aaf4 --- /dev/null +++ b/tests/integration/get-project-status.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { getProjectStatusService } from '../../src/mcp/tools/get-project-status.js'; +import { createItemService } from '../../src/mcp/tools/create-item.js'; +import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { recordSessionSummaryService } from '../../src/mcp/tools/record-session-summary.js'; +import { withReadSnapshot } from '../../src/db/tx.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll, UNKNOWN_UUID } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('kt_get_project_status', () => { + it('positive: rolls up tracks with item counts and recent events', async () => { + const { project_id } = await registerProjectService(pool, config, { + name: 'P', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + const { track_id } = await createTrackService(pool, config, { + project_id, + title: 'Auth overhaul', + depends_on: [], + source_doc_ref: undefined, + }); + const item1 = await createItemService(pool, config, { + project_id, + track_id, + title: 'Item 1', + sequence_position: undefined, + depends_on: [], + }); + await pool.query(`UPDATE items SET status = 'done' WHERE id = $1`, [item1.item_id]); + await createItemService(pool, config, { + project_id, + track_id, + title: 'Item 2', + sequence_position: undefined, + depends_on: [], + }); + await recordSessionSummaryService(pool, config, { + project_id, + track_id, + summary_text: 'Wired up JWT refresh flow.', + files_touched: [], + items_touched: [], + }); + + const status = await getProjectStatusService(pool, config, { project_id }); + + expect(status.tracks).toHaveLength(1); + expect(status.tracks[0]).toMatchObject({ + track_id, + title: 'Auth overhaul', + status: 'on_track', + item_counts: { pending: 1, in_progress: 0, done: 1, blocked: 0 }, + }); + expect(status.recent_events).toHaveLength(1); + expect(status.recent_events[0]).toMatchObject({ + event_type: 'session_summary', + track_id, + summary_text: 'Wired up JWT refresh flow.', + }); + expect(status.drift_flags).toEqual([]); + }); + + it('negative: 404 when project does not exist', async () => { + await expect( + getProjectStatusService(pool, config, { project_id: UNKNOWN_UUID }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + // adversarial-review P2: the three roll-up queries used to run on + // separate pool connections/snapshots, so a concurrent commit landing + // mid-flight could mix pre- and post-commit state. The fix + // (withReadSnapshot: one client, one REPEATABLE READ transaction) is + // exercised directly here against a genuine concurrent commit, proving + // the actual mechanism kt_get_project_status now relies on — a + // timing-based test against the full service call couldn't force the + // interleaving deterministically. + it('positive: withReadSnapshot holds one consistent view across a concurrent commit mid-transaction', async () => { + const { project_id } = await registerProjectService(pool, config, { + name: 'P', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + await createTrackService(pool, config, { + project_id, + title: 'Track A', + depends_on: [], + source_doc_ref: undefined, + }); + + let countBeforeConcurrentCommit = -1; + let countAfterConcurrentCommit = -1; + await withReadSnapshot(pool, async (client) => { + const before = await client.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM tracks WHERE project_id = $1', + [project_id], + ); + countBeforeConcurrentCommit = before.rows[0]?.n ?? -1; + + // A fully separate connection commits a second track after this + // snapshot was already established. + await createTrackService(pool, config, { + project_id, + title: 'Track B (concurrent)', + depends_on: [], + source_doc_ref: undefined, + }); + + const after = await client.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM tracks WHERE project_id = $1', + [project_id], + ); + countAfterConcurrentCommit = after.rows[0]?.n ?? -1; + }); + + expect(countBeforeConcurrentCommit).toBe(1); + // Without REPEATABLE READ, this second read on the same transaction + // would already see the concurrently committed second track — exactly + // the torn-snapshot behavior the fix prevents. + expect(countAfterConcurrentCommit).toBe(1); + + const finalCount = await pool.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM tracks WHERE project_id = $1', + [project_id], + ); + expect(finalCount.rows[0]?.n).toBe(2); + }); +}); diff --git a/tests/integration/health-route.test.ts b/tests/integration/health-route.test.ts new file mode 100644 index 0000000..0606511 --- /dev/null +++ b/tests/integration/health-route.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { createPool } from '../../src/db/pool.js'; +import { HEALTH_CHECK_STATEMENT_TIMEOUT_MS } from '../../src/server/health-route.js'; +import { getTestConfig } from './helpers.js'; + +// adversarial-review P2: registerHealthRoutes's pingDb used to race +// pool.query('SELECT 1') against a client-side setTimeout — that only +// stopped the /health *request* from waiting, it never canceled the query +// itself. A slow/unreachable DB left the query running to completion (or +// to the much larger default statement_timeout) on one of the health +// pool's 2 connections regardless, so repeated timed-out checks could pile +// up work behind that small pool. The fix sets `statement_timeout` on the +// health pool itself, so Postgres — not just the JS side — cancels a slow +// query at the bound and frees the connection back to the pool. +// +// This test exercises the exact mechanism the fix relies on (a pool built +// with the same override shape health-route.ts uses, including its +// exported timeout constant) against a genuinely slow query, rather than +// the /health route itself — there's no way to make the route's hardcoded +// `SELECT 1` slow from the outside. +describe('health pool statement_timeout (adversarial-review P2)', () => { + const config = getTestConfig(); + + it('cancels a slow query at the configured statement_timeout and leaves the pool usable afterward', async () => { + const pool = createPool(config, { + max: 2, + connectionTimeoutMillis: 2000, + idleTimeoutMillis: 10000, + statement_timeout: HEALTH_CHECK_STATEMENT_TIMEOUT_MS, + }); + try { + const start = Date.now(); + await expect(pool.query('SELECT pg_sleep(3)')).rejects.toThrow(/statement timeout/i); + const elapsedMs = Date.now() - start; + // Cancelled at ~HEALTH_CHECK_STATEMENT_TIMEOUT_MS (1s), nowhere near + // the full 3s the query itself asked to sleep for. Before the fix + // (no statement_timeout override — inherits the much larger default), + // this same query would have run to completion instead of rejecting. + expect(elapsedMs).toBeLessThan(2500); + + // The connection the cancelled query held must come back to the pool + // usable, not leaked/stuck — a subsequent query on this same + // 2-connection pool must still succeed promptly. + const result = await pool.query<{ one: number }>('SELECT 1 AS one'); + expect(result.rows[0]).toEqual({ one: 1 }); + } finally { + await pool.end(); + } + }); +}); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts new file mode 100644 index 0000000..4febe5c --- /dev/null +++ b/tests/integration/helpers.ts @@ -0,0 +1,66 @@ +// Shared test setup: a pg.Pool against the real local scratch Postgres +// database (migrations/001_init.sql already applied — see scripts/migrate.ts), +// a matching Config, and a truncate helper for test isolation. +import { Pool } from 'pg'; +import type { Config } from '../../src/config/env.js'; + +const TEST_DATABASE_URL = + process.env.DATABASE_URL ?? + 'postgres://knotrack_app:knotrack_dev_pw@127.0.0.1:5432/knotrack_scratch'; + +let pool: Pool | undefined; + +export function getTestPool(): Pool { + pool ??= new Pool({ connectionString: TEST_DATABASE_URL, max: 5 }); + return pool; +} + +export function getTestConfig(): Config { + return { + databaseUrl: TEST_DATABASE_URL, + apiTokens: ['kt_test_token'], + encryptionKey: Buffer.from('01234567890123456789012345678901', 'utf8').subarray(0, 32), + nodeEnv: 'test', + port: 0, + host: '127.0.0.1', + databaseSslMode: 'disable', + dbSslRejectUnauthorized: true, + dbStatementTimeoutMs: 30000, + dbPoolMax: 5, + driftScanTrackCap: 500, + driftScanItemCap: 5000, + driftScanTimeoutMs: 5000, + roadmapTrackCap: 200, + roadmapItemPerTrackCap: 100, + staleTrackDays: 14, + nextStepsLimit: 5, + githubSyncTimeoutMs: 8000, + linearSyncTimeoutMs: 8000, + logLevel: 'error', + }; +} + +/** Wipes every table between tests so each test starts from a clean + * slate, without dropping/recreating the schema (the migration stays + * applied for the whole test run). */ +export async function truncateAll(): Promise { + const p = getTestPool(); + await p.query( + `TRUNCATE TABLE + drift_flags, decisions, events, api_tokens, + item_dependencies, items, + track_dependencies, tracks, + adapters, projects + RESTART IDENTITY CASCADE`, + ); +} + +export async function closeTestPool(): Promise { + if (pool) { + await pool.end(); + pool = undefined; + } +} + +export const NIL_UUID = '00000000-0000-0000-0000-000000000000'; +export const UNKNOWN_UUID = '99999999-9999-4999-8999-999999999999'; diff --git a/tests/integration/http.test.ts b/tests/integration/http.test.ts new file mode 100644 index 0000000..15fcef9 --- /dev/null +++ b/tests/integration/http.test.ts @@ -0,0 +1,286 @@ +// Full-stack tests through the real Fastify app (via .inject(), no open +// port needed) — covers auth failure/success and the closed-schema +// (additionalProperties: false) validation path, which lives at the +// transport layer rather than inside any single tool's service function. +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildFastify } from '../../src/server/fastify.js'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); +let app: FastifyInstance; + +beforeAll(() => { + app = buildFastify(pool, config, new Date()); +}); + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await app.close(); + await closeTestPool(); +}); + +function rpcCall(name: string, args: Record, id = 1) { + return { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }; +} + +function parseSseBody(body: string): unknown { + // Streamable HTTP responses (when the client accepts text/event-stream) + // come back as `event: message\ndata: \n\n`. Pull the JSON out. + const dataLine = body.split('\n').find((line) => line.startsWith('data: ')); + if (!dataLine) { + throw new Error(`no SSE data line found in body: ${body}`); + } + return JSON.parse(dataLine.slice('data: '.length)); +} + +describe('GET /health pool isolation (adversarial-review security-1)', () => { + it('positive: /health still responds promptly even when the main application pool is fully checked out', async () => { + // Exhausts the exact shared resource the finding describes: hold every + // connection the main pool can hand out, simulating either a flood of + // /health itself (pre-fix) or ordinary MCP tool load. With a dedicated + // health pool this cannot affect /health at all. + const heldClients = await Promise.all( + Array.from({ length: config.dbPoolMax }, () => pool.connect()), + ); + try { + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ status: 'ok', db: 'ok' }); + } finally { + heldClients.forEach((c) => c.release()); + } + }); +}); + +describe('POST /mcp auth (TRD §4)', () => { + it('negative: rejects before body parsing — an unauthenticated request with an unparseable JSON body still gets a clean 401, not a parse-error 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + payload: '{not valid json', + }); + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: { code: 'UNAUTHORIZED', http_status_equivalent: 401 }, + }); + }); + + it('negative: rejects a request with no Authorization header — 401 UNAUTHORIZED', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ + error: { code: 'UNAUTHORIZED', http_status_equivalent: 401 }, + }); + }); + + it('negative: rejects a request with a wrong bearer token — 401 UNAUTHORIZED', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: 'Bearer not-a-real-token', + }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }); + }); + + it('positive: accepts a request with a valid bearer token — tools/list returns all 14 tools', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + expect(response.statusCode).toBe(200); + const body = parseSseBody(response.body) as { result: { tools: Array<{ name: string }> } }; + expect(body.result.tools).toHaveLength(14); + const names = body.result.tools.map((t) => t.name).sort(); + expect(names).toEqual( + [ + 'kt_check_drift', + 'kt_create_item', + 'kt_create_track', + 'kt_get_next_steps', + 'kt_get_project_status', + 'kt_get_track', + 'kt_list_tracks', + 'kt_record_decision', + 'kt_record_session_summary', + 'kt_register_project', + 'kt_render_roadmap', + 'kt_sync_to_github', + 'kt_sync_to_linear', + 'kt_update_item_status', + ].sort(), + ); + }); +}); + +describe('POST /mcp closed input schemas (TRD §3.0)', () => { + // adversarial-review P1 (documented, not fixed — see + // src/mcp/tool-helpers.ts's header comment and TRD §3.1's "known gap" + // bullet): the SDK rejects this before KnoTrack's own tool handler / + // runTool ever runs, so the response is *not* JSON.stringify of the + // documented VALIDATION_ERROR envelope — it's the SDK's own plain-text + // validation message. This assertion is intentionally loose (matches + // either the SDK's wording or the field name) rather than asserting the + // envelope shape, because the envelope shape is not what actually comes + // back on this path. + it('negative: an unknown property in a tool call is rejected as a validation failure', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: rpcCall('kt_get_project_status', { + project_id: '3f1a2b4c-9d3e-4a2f-8b21-6f0e2c9a1d55', + unexpected_extra_field: true, + }), + }); + expect(response.statusCode).toBe(200); // TRD §3.1: tool-execution failures are HTTP 200 + const body = parseSseBody(response.body) as { + result: { isError: boolean; content: Array<{ text: string }> }; + }; + expect(body.result.isError).toBe(true); + expect(body.result.content[0]?.text).toMatch(/unrecognized|unexpected_extra_field/i); + }); + + it('positive: a real tool call round-trips end to end over HTTP with auth', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: rpcCall('kt_register_project', { + name: 'HTTP smoke test', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + }), + }); + expect(response.statusCode).toBe(200); + const body = parseSseBody(response.body) as { + result: { structuredContent: { project_id: string }; isError?: boolean }; + }; + expect(body.result.isError).toBeUndefined(); + expect(body.result.structuredContent.project_id).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it('stub tools respond with a clear not-implemented error rather than silently succeeding', async () => { + const { project_id } = await registerProjectService(pool, config, { + name: 'For stub test', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: rpcCall('kt_check_drift', { project_id }), + }); + const body = parseSseBody(response.body) as { + result: { isError: boolean; content: Array<{ text: string }> }; + }; + expect(body.result.isError).toBe(true); + const envelope = JSON.parse(body.result.content[0]?.text ?? '{}') as { + error: { message: string }; + }; + expect(envelope.error.message).toMatch(/not yet implemented/i); + }); + + // CodeRabbit raised a Critical finding that every tool handler casting + // `rawArgs as SomeInputType` instead of calling `SomeInputSchema.parse` + // meant Zod's `.default([])` on `kt_record_session_summary`'s + // `files_touched`/`items_touched` would never actually run, so a call + // omitting them would reach `Array.from(new Set(input.items_touched))` + // with `undefined` and throw. Investigation (see src/mcp/tools/*.ts and + // this pinned SDK version's server/mcp.js) found the handler was never + // actually reachable with undefined fields in the first place: the SDK + // (`McpServer.setToolRequestHandlers`) already runs + // `this.validateToolInput(tool, request.params.arguments, ...)` — a real + // `safeParseAsync` against the exact same Zod schema, defaults included + // — and passes *that* parsed result into the handler, before the + // handler's own body (and thus its cast) ever runs; a request missing + // these fields never reaches `Array.from(new Set(undefined))` even + // pre-fix. This test proves that end to end over the real HTTP/JSON-RPC + // path. The `.parse()` calls this fix round added to every handler are + // still correct defensive practice (and are exercised implicitly by + // this same request), just not what was making this particular call + // succeed. + it('positive: kt_record_session_summary omitting files_touched/items_touched applies their [] defaults rather than throwing', async () => { + const { project_id } = await registerProjectService(pool, config, { + name: 'Defaults test', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + const { track_id } = await createTrackService(pool, config, { + project_id, + title: 'T', + depends_on: [], + source_doc_ref: undefined, + }); + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: rpcCall('kt_record_session_summary', { + project_id, + track_id, + summary_text: 'Did the thing, no files/items listed.', + // files_touched and items_touched deliberately omitted. + }), + }); + expect(response.statusCode).toBe(200); + const body = parseSseBody(response.body) as { + result: { + isError?: boolean; + structuredContent: { event_id: string; drift_flags_raised: unknown[] }; + }; + }; + expect(body.result.isError).toBeUndefined(); + expect(body.result.structuredContent.event_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(body.result.structuredContent.drift_flags_raised).toEqual([]); + }); +}); diff --git a/tests/integration/mcp-route-error-handling.test.ts b/tests/integration/mcp-route-error-handling.test.ts new file mode 100644 index 0000000..109086c --- /dev/null +++ b/tests/integration/mcp-route-error-handling.test.ts @@ -0,0 +1,72 @@ +// CodeRabbit re-review: registerMcpRoute's catch block only terminated the +// response when `!reply.raw.headersSent && !reply.raw.writableEnded` (the +// branch that writes a fresh 500 JSON body). If `transport.handleRequest` +// had already written headers (e.g. it started streaming an SSE response) +// before rejecting, neither that branch nor anything else ran — the +// response was left open and the client would hang until its own timeout. +// +// This subclasses the real StreamableHTTPServerTransport, overriding only +// `handleRequest` to write headers/a partial body and then reject — the +// exact "headers already sent, then an error" shape the fix targets — so +// the rest of registerMcpRoute's real connect/hijack/catch logic runs +// unmodified. +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { getTestConfig, getTestPool, closeTestPool } from './helpers.js'; + +vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', async (importOriginal) => { + const actual = + await importOriginal(); + + class HeadersSentThenThrowsTransport extends actual.StreamableHTTPServerTransport { + override handleRequest( + _req: import('node:http').IncomingMessage, + res: import('node:http').ServerResponse, + _parsedBody?: unknown, + ): Promise { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('event: message\ndata: {}\n\n'); + return Promise.reject(new Error('simulated failure after headers were already sent')); + } + } + + return { ...actual, StreamableHTTPServerTransport: HeadersSentThenThrowsTransport }; +}); + +const pool = getTestPool(); +const config = getTestConfig(); +let app: FastifyInstance; + +beforeAll(async () => { + // Imported after the mock above is registered so registerMcpRoute picks + // up the throwing transport subclass. + const { buildFastify } = await import('../../src/server/fastify.js'); + app = buildFastify(pool, config, new Date()); +}); + +afterAll(async () => { + await app.close(); + await closeTestPool(); +}); + +describe('POST /mcp error handling when headers were already sent (CodeRabbit re-review)', () => { + it('ends the response instead of hanging when handleRequest throws after writing headers', async () => { + const response = await app.inject({ + method: 'POST', + url: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${config.apiTokens[0]}`, + }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + + // Before the fix, this request would never resolve (the response was + // never ended) and this test would fail on vitest's testTimeout + // instead of reaching any assertion below. + expect(response.statusCode).toBe(200); + expect(response.body).toBe('event: message\ndata: {}\n\n'); + expect(response.raw.res.writableEnded).toBe(true); + }); +}); diff --git a/tests/integration/migrate.test.ts b/tests/integration/migrate.test.ts new file mode 100644 index 0000000..068e915 --- /dev/null +++ b/tests/integration/migrate.test.ts @@ -0,0 +1,308 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { applyMigrations, MIGRATION_ADVISORY_LOCK_KEY } from '../../scripts/migrate.js'; +import { closeTestPool, getTestPool } from './helpers.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pool = getTestPool(); + +function makeScratchMigrationsDir(files: Record): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'knotrack-migrate-test-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(path.join(dir, name), content, 'utf8'); + } + return dir; +} + +afterEach(async () => { + // Cleanup shared with every test below — scratch tables/rows this file's + // migrations may have created on the real local scratch DB, plus the + // schema_migrations bookkeeping rows so re-running this suite doesn't + // hit "already applied" on names it reuses. + await pool.query('DROP TABLE IF EXISTS knotrack_migrate_test_ok'); + await pool.query('DROP TABLE IF EXISTS knotrack_migrate_test_atomic'); + await pool.query('DROP TRIGGER IF EXISTS trg_knotrack_migrate_test_fail ON schema_migrations'); + await pool.query('DROP FUNCTION IF EXISTS knotrack_migrate_test_fail_insert()'); + await pool.query( + `DELETE FROM schema_migrations WHERE name IN ('900_test_ok.sql', '901_test_atomic.sql')`, + ); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('scripts/migrate.ts applyMigrations', () => { + it('positive: applies a migration file and records it in schema_migrations', async () => { + const dir = makeScratchMigrationsDir({ + '900_test_ok.sql': 'BEGIN;\nCREATE TABLE knotrack_migrate_test_ok (id int);\nCOMMIT;\n', + }); + try { + const client = await pool.connect(); + try { + const appliedCount = await applyMigrations(client, dir); + expect(appliedCount).toBe(1); + } finally { + client.release(); + } + + const table = await pool.query( + `SELECT to_regclass('public.knotrack_migrate_test_ok') AS exists`, + ); + expect(table.rows[0].exists).toBe('knotrack_migrate_test_ok'); + + const row = await pool.query('SELECT name FROM schema_migrations WHERE name = $1', [ + '900_test_ok.sql', + ]); + expect(row.rowCount).toBe(1); + + // Second call is a no-op (already applied) — matches skip-logging path. + const client2 = await pool.connect(); + try { + const appliedAgain = await applyMigrations(client2, dir); + expect(appliedAgain).toBe(0); + } finally { + client2.release(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // adversarial-review P2: the DDL used to commit (via its own embedded + // BEGIN/COMMIT) before the separate schema_migrations INSERT — a failure + // between the two left the schema changed with no record of it. This + // forces exactly that INSERT to fail (via a trigger on schema_migrations + // itself) and proves the DDL's effects are rolled back together with it + // now that both run in one runner-controlled transaction. + it('negative: a failure recording schema_migrations rolls back the DDL too, not just the bookkeeping row', async () => { + await pool.query(` + CREATE OR REPLACE FUNCTION knotrack_migrate_test_fail_insert() RETURNS trigger AS $$ + BEGIN + IF NEW.name = '901_test_atomic.sql' THEN + RAISE EXCEPTION 'forced failure for atomicity test'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + `); + await pool.query(` + CREATE TRIGGER trg_knotrack_migrate_test_fail + BEFORE INSERT ON schema_migrations + FOR EACH ROW EXECUTE FUNCTION knotrack_migrate_test_fail_insert(); + `); + + const dir = makeScratchMigrationsDir({ + '901_test_atomic.sql': + 'BEGIN;\nCREATE TABLE knotrack_migrate_test_atomic (id int);\nCOMMIT;\n', + }); + try { + const client = await pool.connect(); + try { + await expect(applyMigrations(client, dir)).rejects.toThrow(/forced failure/i); + } finally { + client.release(); + } + + // The DDL must NOT have persisted — it was rolled back along with + // the failed schema_migrations insert, exactly the property that + // was missing before the fix (two separate transactions would have + // left this table committed here). + const table = await pool.query( + `SELECT to_regclass('public.knotrack_migrate_test_atomic') AS exists`, + ); + expect(table.rows[0].exists).toBeNull(); + + const row = await pool.query('SELECT name FROM schema_migrations WHERE name = $1', [ + '901_test_atomic.sql', + ]); + expect(row.rowCount).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // CodeRabbit re-review Critical: LEADING_BEGIN used to be + // `/^\s*BEGIN;\s*/i`, which only tolerated whitespace before `BEGIN;`. A + // migration starting with a `-- ...` comment header (exactly + // migrations/003_drift_flags_open_unique.sql's actual shape) failed + // stripTransactionWrapper's own "must start with BEGIN;" check and threw, + // even though the file is a perfectly valid BEGIN/COMMIT-wrapped + // migration. This fixture models that file's header shape and proves the + // migration now applies instead of throwing. + it('positive: applies a migration file whose comment header precedes BEGIN; (models migrations/003)', async () => { + const dir = makeScratchMigrationsDir({ + '903_test_comment_header.sql': + '-- KnoTrack — 903_test_comment_header.sql\n' + + '--\n' + + '-- Multi-line SQL comment header, exactly the shape\n' + + '-- migrations/003_drift_flags_open_unique.sql uses: several\n' + + '-- `-- ...` comment lines and a blank line before BEGIN;.\n' + + '\n' + + 'BEGIN;\n' + + '\n' + + 'CREATE TABLE knotrack_migrate_test_comment_header (id int);\n' + + '\n' + + 'COMMIT;\n', + }); + try { + const client = await pool.connect(); + try { + const appliedCount = await applyMigrations(client, dir); + expect(appliedCount).toBe(1); + } finally { + client.release(); + } + + const table = await pool.query( + `SELECT to_regclass('public.knotrack_migrate_test_comment_header') AS exists`, + ); + expect(table.rows[0].exists).toBe('knotrack_migrate_test_comment_header'); + + const row = await pool.query('SELECT name FROM schema_migrations WHERE name = $1', [ + '903_test_comment_header.sql', + ]); + expect(row.rowCount).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + await pool.query('DROP TABLE IF EXISTS knotrack_migrate_test_comment_header'); + await pool.query( + `DELETE FROM schema_migrations WHERE name = '903_test_comment_header.sql'`, + ); + } + }); + + // Directly exercises the actual shipped migration file, not just a + // fixture modeled on it — the strongest possible proof this specific + // regression (migrations/003 failing to apply) is fixed. + it('positive: applies the actual migrations/003_drift_flags_open_unique.sql file against a scratch table standing in for drift_flags', async () => { + // stripTransactionWrapper only strips BEGIN;/COMMIT; and runs whatever + // DDL is left — it has no knowledge of table names — so a scratch + // `drift_flags` table (dropped in this test's own cleanup, independent + // of the shared afterEach above) lets the real file's + // `CREATE UNIQUE INDEX ... ON drift_flags (...)` statement run as-is. + await pool.query(` + CREATE TABLE drift_flags_test_003 ( + item_id uuid, + kind text, + resolved_at timestamptz + ) + `); + const realFile = readFileSync( + path.join(__dirname, '..', '..', 'migrations', '003_drift_flags_open_unique.sql'), + 'utf8', + ) + .replace(/\bdrift_flags\b/g, 'drift_flags_test_003') + // The real migration's index name would otherwise collide with the + // one the real migrations/003_drift_flags_open_unique.sql already + // created on this scratch database's actual drift_flags table — + // Postgres index names are unique per schema regardless of table. + .replace(/\buq_drift_flags_open_item_kind\b/g, 'uq_drift_flags_test_003_open_item_kind'); + // A distinct filename, not the real migration's name — this scratch + // test database already has migrations/003_drift_flags_open_unique.sql + // itself applied (from setting up the schema), so reusing that exact + // name would just hit the "already applied" skip path instead of + // actually exercising stripTransactionWrapper against this content. + const testFileName = '905_test_real_003_content.sql'; + const dir = makeScratchMigrationsDir({ [testFileName]: realFile }); + try { + const client = await pool.connect(); + try { + const appliedCount = await applyMigrations(client, dir); + expect(appliedCount).toBe(1); + } finally { + client.release(); + } + + const index = await pool.query( + `SELECT indexname FROM pg_indexes WHERE indexname = 'uq_drift_flags_test_003_open_item_kind'`, + ); + expect(index.rowCount).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + await pool.query('DROP TABLE IF EXISTS drift_flags_test_003'); + await pool.query(`DELETE FROM schema_migrations WHERE name = $1`, [testFileName]); + } + }); + + // Major fix: two concurrent applyMigrations calls could both read + // schema_migrations, both pick the same pending file, and race applying + // it. Proves the advisory lock is actually held for the duration of the + // pass (a concurrent pg_try_advisory_lock on the same key fails while a + // run is in flight) and is released once the pass completes (a + // subsequent pg_try_advisory_lock then succeeds) — the exact property + // that serializes two real concurrent runner invocations. + it('holds the migration advisory lock for the whole pass and releases it once done', async () => { + const dir = makeScratchMigrationsDir({ + '904_test_lock.sql': + 'BEGIN;\nSELECT pg_sleep(0.5);\nCREATE TABLE knotrack_migrate_test_lock (id int);\nCOMMIT;\n', + }); + const client = await pool.connect(); + const checkerClient = await pool.connect(); + try { + const runPromise = applyMigrations(client, dir); + + // Poll for the lock being held instead of sleeping a fixed delay — + // a fixed delay can elapse before applyMigrations actually acquires + // the lock under load, letting pg_try_advisory_lock spuriously + // succeed and failing this test even when the runner is correct. + // Release the lock after every successful probe so a slow-to-start + // run doesn't get falsely flagged, and only proceed once a probe + // observes contention (another session actually holds the lock). + const deadline = Date.now() + 5_000; + let lockObserved = false; + while (Date.now() < deadline) { + const probe = await checkerClient.query<{ acquired: boolean }>( + 'SELECT pg_try_advisory_lock($1) AS acquired', + [MIGRATION_ADVISORY_LOCK_KEY], + ); + if (!probe.rows[0]?.acquired) { + lockObserved = true; + break; + } + await checkerClient.query('SELECT pg_advisory_unlock($1)', [MIGRATION_ADVISORY_LOCK_KEY]); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(lockObserved).toBe(true); + + const appliedCount = await runPromise; + expect(appliedCount).toBe(1); + + const afterRun = await checkerClient.query<{ acquired: boolean }>( + 'SELECT pg_try_advisory_lock($1) AS acquired', + [MIGRATION_ADVISORY_LOCK_KEY], + ); + expect(afterRun.rows[0]?.acquired).toBe(true); + await checkerClient.query('SELECT pg_advisory_unlock($1)', [MIGRATION_ADVISORY_LOCK_KEY]); + } finally { + client.release(); + checkerClient.release(); + rmSync(dir, { recursive: true, force: true }); + await pool.query('DROP TABLE IF EXISTS knotrack_migrate_test_lock'); + await pool.query(`DELETE FROM schema_migrations WHERE name = '904_test_lock.sql'`); + } + }); + + it('negative: a migration file without BEGIN;/COMMIT; fails loudly instead of running unwrapped', async () => { + const dir = makeScratchMigrationsDir({ + '902_test_no_wrapper.sql': 'CREATE TABLE knotrack_migrate_test_no_wrapper (id int);\n', + }); + try { + const client = await pool.connect(); + try { + await expect(applyMigrations(client, dir)).rejects.toThrow( + /must start with "BEGIN;".*and end with "COMMIT;"/, + ); + } finally { + client.release(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + await pool.query('DROP TABLE IF EXISTS knotrack_migrate_test_no_wrapper'); + await pool.query(`DELETE FROM schema_migrations WHERE name = '902_test_no_wrapper.sql'`); + } + }); +}); diff --git a/tests/integration/record-session-summary.test.ts b/tests/integration/record-session-summary.test.ts new file mode 100644 index 0000000..2bb5e07 --- /dev/null +++ b/tests/integration/record-session-summary.test.ts @@ -0,0 +1,262 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { recordSessionSummaryService } from '../../src/mcp/tools/record-session-summary.js'; +import { createItemService } from '../../src/mcp/tools/create-item.js'; +import { createTrackService } from '../../src/mcp/tools/create-track.js'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll, UNKNOWN_UUID } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); + +async function makeProjectAndTrack(): Promise<{ projectId: string; trackId: string }> { + const { project_id } = await registerProjectService(pool, config, { + name: 'P', + source_type: 'local', + source_ref: `/tmp/${crypto.randomUUID()}`, + adapters: undefined, + }); + const { track_id } = await createTrackService(pool, config, { + project_id, + title: 'T', + depends_on: [], + source_doc_ref: undefined, + }); + return { projectId: project_id, trackId: track_id }; +} + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('kt_record_session_summary', () => { + it('positive: inserts an event and returns its event_id', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const result = await recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'Did the thing.', + files_touched: ['src/index.ts'], + items_touched: [], + }); + expect(result.event_id).toMatch(/^[0-9a-f-]{36}$/i); + const row = await pool.query('SELECT summary_text, files_touched FROM events WHERE id = $1', [ + result.event_id, + ]); + expect(row.rows[0].summary_text).toBe('Did the thing.'); + expect(row.rows[0].files_touched).toEqual(['src/index.ts']); + }); + + it('positive: scoped drift re-check raises an out_of_sequence flag when an item finished out of order', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const earlier = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Add refresh endpoint', + sequence_position: 1, + depends_on: [], + }); + const later = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Add rotation tests', + sequence_position: 2, + depends_on: [], + }); + // Later item finishes while the earlier one is still pending. + await pool.query(`UPDATE items SET status = 'done' WHERE id = $1`, [later.item_id]); + void earlier; + + const result = await recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'Finished rotation tests early.', + files_touched: [], + items_touched: [], + }); + + expect(result.drift_flags_raised).toHaveLength(1); + // adversarial-review P1: the public flag_type is TRD Appendix C's + // 'SEQUENCE_SKIP', not an uppercased DB kind ('OUT_OF_SEQUENCE'). + expect(result.drift_flags_raised[0]).toMatchObject({ flag_type: 'SEQUENCE_SKIP' }); + + // Calling it again must not re-raise a duplicate open flag for the + // same item. + const second = await recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'Still finished early, second summary.', + files_touched: [], + items_touched: [], + }); + expect(second.drift_flags_raised).toHaveLength(0); + }); + + // adversarial-review P1: bringing the earlier item back into sequence + // used to leave the previously-raised flag open forever — nothing ever + // wrote resolved_at. The scoped re-check must resolve it once the + // condition it was raised for no longer holds. + it('positive: a previously-raised flag is resolved once the out-of-sequence condition clears', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const earlier = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Add refresh endpoint', + sequence_position: 1, + depends_on: [], + }); + const later = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Add rotation tests', + sequence_position: 2, + depends_on: [], + }); + await pool.query(`UPDATE items SET status = 'done' WHERE id = $1`, [later.item_id]); + + const first = await recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'Finished rotation tests early.', + files_touched: [], + items_touched: [], + }); + expect(first.drift_flags_raised).toHaveLength(1); + const flagId = first.drift_flags_raised[0]?.flag_id; + + // Bring the earlier item into sequence too — the condition clears. + await pool.query(`UPDATE items SET status = 'done' WHERE id = $1`, [earlier.item_id]); + + const second = await recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'Caught up the refresh endpoint too.', + files_touched: [], + items_touched: [], + }); + expect(second.drift_flags_raised).toHaveLength(0); + + const flagRow = await pool.query('SELECT resolved_at FROM drift_flags WHERE id = $1', [flagId]); + expect(flagRow.rows[0].resolved_at).not.toBeNull(); + }); + + // adversarial-review P1: hasOpenFlagForItem + insertDriftFlag was a + // check-then-insert with no DB constraint behind it — two concurrent + // scans of the same out-of-sequence item could both observe "not open + // yet" and both insert. The fix backs it with a partial unique index + // (migrations/003) and an atomic ON CONFLICT DO NOTHING insert, so this + // holds deterministically regardless of timing, not just "usually". + it('negative: concurrent scans of the same out-of-sequence item never raise more than one open flag', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Earlier, still pending', + sequence_position: 1, + depends_on: [], + }); + const later = await createItemService(pool, config, { + project_id: projectId, + track_id: trackId, + title: 'Later, finished early', + sequence_position: 2, + depends_on: [], + }); + await pool.query(`UPDATE items SET status = 'done' WHERE id = $1`, [later.item_id]); + + await Promise.all( + Array.from({ length: 6 }, (_, i) => + recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: `Concurrent summary ${i}`, + files_touched: [], + items_touched: [], + }), + ), + ); + + const openFlags = await pool.query( + `SELECT id FROM drift_flags WHERE item_id = $1 AND kind = 'out_of_sequence' AND resolved_at IS NULL`, + [later.item_id], + ); + expect(openFlags.rowCount).toBe(1); + }); + + it('negative: 404 when track does not exist in project', async () => { + const { projectId } = await makeProjectAndTrack(); + await expect( + recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: UNKNOWN_UUID, + summary_text: 'x', + files_touched: [], + items_touched: [], + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + // adversarial-review reliability-4 / P1: findSequenceSkips is O(n^2) in + // the track's item count and KNOTRACK_DRIFT_SCAN_ITEM_CAP bounds it, but + // is documented (TRD §6.3/§7) as a kt_check_drift-scan limit, not a + // reason to refuse an otherwise-valid kt_record_session_summary write. + // Past the cap, the event must still commit — only the scoped drift + // re-check is skipped. Uses a tiny cap override rather than the real + // 5000-item default so the test stays fast. + it('positive: the event still commits when the track has more items than driftScanItemCap allows, drift re-check skipped', async () => { + const cappedConfig = { ...config, driftScanItemCap: 3 }; + const { projectId, trackId } = await makeProjectAndTrack(); + for (let i = 0; i < 4; i += 1) { + await createItemService(pool, cappedConfig, { + project_id: projectId, + track_id: trackId, + title: `Item ${i}`, + sequence_position: undefined, + depends_on: [], + }); + } + + const result = await recordSessionSummaryService(pool, cappedConfig, { + project_id: projectId, + track_id: trackId, + summary_text: 'Too many items in this track.', + files_touched: [], + items_touched: [], + }); + + expect(result.event_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(result.drift_flags_raised).toEqual([]); + const row = await pool.query('SELECT id FROM events WHERE id = $1', [result.event_id]); + expect(row.rowCount).toBe(1); + }); + + it('negative: 422 when an items_touched id belongs to a different track', async () => { + const { projectId, trackId } = await makeProjectAndTrack(); + const otherTrack = await createTrackService(pool, config, { + project_id: projectId, + title: 'Other track', + depends_on: [], + source_doc_ref: undefined, + }); + const itemElsewhere = await createItemService(pool, config, { + project_id: projectId, + track_id: otherTrack.track_id, + title: 'Elsewhere', + sequence_position: undefined, + depends_on: [], + }); + + await expect( + recordSessionSummaryService(pool, config, { + project_id: projectId, + track_id: trackId, + summary_text: 'x', + files_touched: [], + items_touched: [itemElsewhere.item_id], + }), + ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); + }); +}); diff --git a/tests/integration/register-project.test.ts b/tests/integration/register-project.test.ts new file mode 100644 index 0000000..2ca02c9 --- /dev/null +++ b/tests/integration/register-project.test.ts @@ -0,0 +1,132 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { registerProjectService } from '../../src/mcp/tools/register-project.js'; +import { decryptCredential } from '../../src/crypto/credential-cipher.js'; +import { closeTestPool, getTestConfig, getTestPool, truncateAll } from './helpers.js'; + +const pool = getTestPool(); +const config = getTestConfig(); + +beforeEach(async () => { + await truncateAll(); +}); + +afterAll(async () => { + await closeTestPool(); +}); + +describe('kt_register_project', () => { + it('positive: registers a new project and returns a project_id', async () => { + const result = await registerProjectService(pool, config, { + name: 'KnoTrack', + source_type: 'github', + source_ref: 'pjpoulose/knotrack', + adapters: undefined, + }); + expect(result.project_id).toMatch(/^[0-9a-f-]{36}$/i); + + const row = await pool.query('SELECT * FROM projects WHERE id = $1', [result.project_id]); + expect(row.rows[0]).toMatchObject({ + name: 'KnoTrack', + source_type: 'github', + source_ref: 'pjpoulose/knotrack', + }); + }); + + it('positive: upserts on (source_type, source_ref) — same id, updated name, no duplicate row', async () => { + const first = await registerProjectService(pool, config, { + name: 'Old Name', + source_type: 'local', + source_ref: '/tmp/some/repo', + adapters: undefined, + }); + + const second = await registerProjectService(pool, config, { + name: 'New Name', + source_type: 'local', + source_ref: '/tmp/some/repo', + adapters: undefined, + }); + + expect(second.project_id).toBe(first.project_id); + + const rows = await pool.query( + 'SELECT * FROM projects WHERE source_type = $1 AND source_ref = $2', + ['local', '/tmp/some/repo'], + ); + expect(rows.rowCount).toBe(1); + expect(rows.rows[0].name).toBe('New Name'); + }); + + it('negative: adapter credentials are encrypted at rest, never stored as plaintext', async () => { + const result = await registerProjectService(pool, config, { + name: 'Has Secrets', + source_type: 'github', + source_ref: 'acme/widgets', + adapters: { + github: { personal_access_token: 'ghp_super_secret_value', repo: undefined }, + }, + }); + + const adapterRow = await pool.query( + 'SELECT encrypted_credential, config FROM adapters WHERE project_id = $1 AND type = $2', + [result.project_id, 'github'], + ); + expect(adapterRow.rowCount).toBe(1); + const encrypted: Buffer = adapterRow.rows[0].encrypted_credential; + // The raw secret must never appear in the stored bytes. + expect(encrypted.toString('utf8')).not.toContain('ghp_super_secret_value'); + expect(encrypted.toString('base64')).not.toContain( + Buffer.from('ghp_super_secret_value').toString('base64'), + ); + // But it must decrypt back to the original value with the right key. + expect(decryptCredential(encrypted, config.encryptionKey)).toBe('ghp_super_secret_value'); + // Non-secret metadata defaults repo to source_ref for a github project. + expect(adapterRow.rows[0].config).toMatchObject({ repo: 'acme/widgets', connected: true }); + }); + + // adversarial-review P2: an adapter-storage failure used to attach the + // raw driver/cipher error message to `details.cause`, which runTool + // serializes verbatim into the client-facing envelope — leaking internals + // through what TRD §3.1 documents as a generic 500. A too-short + // encryption key makes `encryptCredential` throw a real Node crypto error + // ("Invalid key length") without needing to fake a DB failure, exercising + // the same catch block. + it('negative: an adapter-storage failure logs the cause server-side but never returns it to the client', async () => { + const loggedErrors: unknown[] = []; + const spyLogger = { error: (obj: unknown) => loggedErrors.push(obj) }; + const badConfig = { ...config, encryptionKey: Buffer.alloc(10) }; + + let thrown: unknown; + try { + await registerProjectService( + pool, + badConfig, + { + name: 'Bad key', + source_type: 'github', + source_ref: 'acme/broken-key', + adapters: { + github: { personal_access_token: 'ghp_whatever', repo: undefined }, + }, + }, + spyLogger, + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ code: 'INTERNAL_ERROR' }); + const envelope = ( + thrown as { toEnvelope: () => { error: Record } } + ).toEnvelope().error; + // No `details` at all — in particular, no leaked `details.cause` + // carrying the raw "Invalid key length" crypto error text. + expect(envelope.details).toBeUndefined(); + expect(JSON.stringify(envelope)).not.toMatch(/invalid key length/i); + + // But the cause was not silently swallowed — it went to the server log. + expect(loggedErrors).toHaveLength(1); + const loggedError = (loggedErrors[0] as { err: Error }).err; + expect(loggedError.message).toMatch(/invalid key length/i); + }); +}); diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts new file mode 100644 index 0000000..bfaedc2 --- /dev/null +++ b/tests/unit/auth.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { createAuthPreHandler, isAuthorizedToken } from '../../src/server/auth.js'; + +describe('isAuthorizedToken (TRD §4)', () => { + const configured = ['kt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'kt_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']; + + it('authorizes a token matching any entry in the pool', () => { + expect(isAuthorizedToken(configured[0]!, configured)).toBe(true); + expect(isAuthorizedToken(configured[1]!, configured)).toBe(true); + }); + + it('rejects a token matching none of the pool', () => { + expect(isAuthorizedToken('kt_totally_unknown_token', configured)).toBe(false); + }); + + it('rejects the empty string', () => { + expect(isAuthorizedToken('', configured)).toBe(false); + }); + + it('handles tokens of different lengths without throwing', () => { + expect(() => isAuthorizedToken('short', configured)).not.toThrow(); + expect(isAuthorizedToken('short', configured)).toBe(false); + }); +}); + +// The preHandler hook itself (header parsing, 401 short-circuits, calling +// `done()` on success) is otherwise only exercised indirectly through the +// integration suite's real HTTP requests. That left it with near-zero +// direct unit coverage — flagged by Stryker mutation testing on this +// security-critical path (auth.ts scored 18.75% under unit-only scope +// vs. 60%+ elsewhere). These tests close that gap directly. +describe('createAuthPreHandler (TRD §4)', () => { + const configured = ['kt_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']; + + // Returns the mock fns alongside the typed reply so assertions read from + // plain vi.fn() references (`code`/`send`) rather than `reply.code` / + // `reply.send` — going through the typed FastifyReply interface there + // trips @typescript-eslint/unbound-method, since the linter can't see + // through the `as unknown as FastifyReply` cast to know they're mocks. + function fakeReply(): { + reply: FastifyReply; + code: ReturnType; + send: ReturnType; + } { + const code = vi.fn(); + const send = vi.fn(); + const reply = { code, send } as unknown as FastifyReply; + code.mockReturnValue(reply); + return { reply, code, send }; + } + + function fakeRequest(authorization?: string): FastifyRequest { + return { headers: { authorization } } as unknown as FastifyRequest; + } + + const UNAUTHORIZED_BODY = { + error: { + code: 'UNAUTHORIZED', + http_status_equivalent: 401, + message: 'missing or invalid bearer token', + }, + }; + + it('rejects a missing Authorization header with 401 and the error envelope', () => { + const handler = createAuthPreHandler(configured); + const { reply, code, send } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest(undefined), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(send).toHaveBeenCalledWith(UNAUTHORIZED_BODY); + expect(done).not.toHaveBeenCalled(); + }); + + it('rejects a header missing the "Bearer " scheme with 401', () => { + const handler = createAuthPreHandler(configured); + const { reply, code, send } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest(configured[0]), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(send).toHaveBeenCalledWith(UNAUTHORIZED_BODY); + expect(done).not.toHaveBeenCalled(); + }); + + it('rejects a lowercase "bearer" scheme (case-sensitive check) with 401', () => { + const handler = createAuthPreHandler(configured); + const { reply, code } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest(`bearer ${configured[0]}`), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(done).not.toHaveBeenCalled(); + }); + + it('rejects an empty token after "Bearer " with 401', () => { + const handler = createAuthPreHandler(configured); + const { reply, code } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest('Bearer '), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(done).not.toHaveBeenCalled(); + }); + + it('rejects a token containing a space with 401', () => { + const handler = createAuthPreHandler(configured); + const { reply, code } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest('Bearer has a space'), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(done).not.toHaveBeenCalled(); + }); + + it('rejects a well-formed but unauthorized token with 401', () => { + const handler = createAuthPreHandler(configured); + const { reply, code, send } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest('Bearer kt_totally_unknown_token'), reply, done); + expect(code).toHaveBeenCalledWith(401); + expect(send).toHaveBeenCalledWith(UNAUTHORIZED_BODY); + expect(done).not.toHaveBeenCalled(); + }); + + it('calls done() with no reply for a valid Bearer token', () => { + const handler = createAuthPreHandler(configured); + const { reply, code, send } = fakeReply(); + const done = vi.fn(); + handler(fakeRequest(`Bearer ${configured[0]}`), reply, done); + expect(done).toHaveBeenCalledWith(); + expect(code).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/credential-cipher.test.ts b/tests/unit/credential-cipher.test.ts new file mode 100644 index 0000000..1123e55 --- /dev/null +++ b/tests/unit/credential-cipher.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { decryptCredential, encryptCredential } from '../../src/crypto/credential-cipher.js'; + +const KEY = Buffer.alloc(32, 7); +const OTHER_KEY = Buffer.alloc(32, 9); + +describe('credential-cipher', () => { + it('round-trips a secret through encrypt/decrypt', () => { + const packed = encryptCredential('ghp_super_secret', KEY); + expect(decryptCredential(packed, KEY)).toBe('ghp_super_secret'); + }); + + it('produces a different IV (and therefore different ciphertext) each call', () => { + const a = encryptCredential('same plaintext', KEY); + const b = encryptCredential('same plaintext', KEY); + expect(a.equals(b)).toBe(false); + }); + + it('never contains the plaintext as a byte substring', () => { + const packed = encryptCredential('findable-plaintext-marker', KEY); + expect(packed.includes(Buffer.from('findable-plaintext-marker'))).toBe(false); + }); + + it('fails to decrypt with the wrong key', () => { + const packed = encryptCredential('secret', KEY); + expect(() => decryptCredential(packed, OTHER_KEY)).toThrow(); + }); + + it('fails to decrypt tampered ciphertext (auth tag mismatch)', () => { + const packed = encryptCredential('secret', KEY); + const tampered = Buffer.from(packed); + tampered[tampered.length - 1] = (tampered[tampered.length - 1] ?? 0) ^ 0xff; + expect(() => decryptCredential(tampered, KEY)).toThrow(); + }); +}); diff --git a/tests/unit/dependency-graph.test.ts b/tests/unit/dependency-graph.test.ts new file mode 100644 index 0000000..dc9875b --- /dev/null +++ b/tests/unit/dependency-graph.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { hasCycle, wouldCreateCycle } from '../../src/domain/dependency-graph.js'; + +describe('hasCycle', () => { + it('returns false for an empty graph', () => { + expect(hasCycle([])).toBe(false); + }); + + it('returns false for a plain DAG', () => { + // A -> B -> C, A -> C + expect( + hasCycle([ + { from: 'A', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'A', to: 'C' }, + ]), + ).toBe(false); + }); + + it('detects a direct two-node cycle', () => { + expect( + hasCycle([ + { from: 'A', to: 'B' }, + { from: 'B', to: 'A' }, + ]), + ).toBe(true); + }); + + it('detects a longer multi-hop cycle', () => { + expect( + hasCycle([ + { from: 'A', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'C', to: 'D' }, + { from: 'D', to: 'A' }, + ]), + ).toBe(true); + }); +}); + +describe('wouldCreateCycle', () => { + it('returns false when the new node only points at existing, older nodes (the normal case)', () => { + // Existing DAG: B -> C + const existing = [{ from: 'B', to: 'C' }]; + // New node A depends on B — cannot create a cycle since nothing + // points at A yet (TRD §3.6's "no operation can point back at a + // freshly created node" observation). + expect(wouldCreateCycle(existing, 'A', ['B'])).toBe(false); + }); + + it('de-duplicates repeated ids in depends_on rather than erroring', () => { + const existing: never[] = []; + expect(wouldCreateCycle(existing, 'A', ['B', 'B', 'B'])).toBe(false); + }); + + it('flags a pre-existing cycle in the stored graph as a systemic invariant violation', () => { + // Simulates data that predates the invariant (or was written by a + // hypothetical future tool) — see TRD §3.6's rationale for why the + // check runs unconditionally rather than only when it's reachable + // through today's tool set. + const existingCyclic = [ + { from: 'X', to: 'Y' }, + { from: 'Y', to: 'X' }, + ]; + expect(wouldCreateCycle(existingCyclic, 'NEW', [])).toBe(true); + }); +}); diff --git a/tests/unit/drift-detector.test.ts b/tests/unit/drift-detector.test.ts new file mode 100644 index 0000000..4b3f92e --- /dev/null +++ b/tests/unit/drift-detector.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { findSequenceSkips } from '../../src/domain/drift-detector.js'; +import type { ItemRow } from '../../src/db/queries/items.js'; + +function item(overrides: Partial): ItemRow { + return { + id: overrides.id ?? 'item-id', + track_id: 'track-id', + title: overrides.title ?? 'Untitled', + sequence_position: overrides.sequence_position ?? 1, + status: overrides.status ?? 'pending', + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }; +} + +describe('findSequenceSkips', () => { + it('finds no skips when items complete in order', () => { + const items = [ + item({ id: '1', sequence_position: 1, status: 'done' }), + item({ id: '2', sequence_position: 2, status: 'pending' }), + ]; + expect(findSequenceSkips(items)).toEqual([]); + }); + + it('flags a later item done while an earlier one is still pending', () => { + const items = [ + item({ id: '1', sequence_position: 1, status: 'pending', title: 'Add refresh endpoint' }), + item({ id: '2', sequence_position: 2, status: 'done', title: 'Add rotation tests' }), + ]; + const findings = findSequenceSkips(items); + expect(findings).toHaveLength(1); + expect(findings[0]?.itemId).toBe('2'); + expect(findings[0]?.detail).toContain('Add rotation tests'); + expect(findings[0]?.detail).toContain('Add refresh endpoint'); + }); + + it('flags a later item done while an earlier one is blocked', () => { + const items = [ + item({ id: '1', sequence_position: 1, status: 'blocked' }), + item({ id: '2', sequence_position: 2, status: 'done' }), + ]; + expect(findSequenceSkips(items)).toHaveLength(1); + }); + + it('does not flag when the earlier item is also done', () => { + const items = [ + item({ id: '1', sequence_position: 1, status: 'done' }), + item({ id: '2', sequence_position: 2, status: 'done' }), + ]; + expect(findSequenceSkips(items)).toEqual([]); + }); +}); diff --git a/tests/unit/load-dotenv.test.ts b/tests/unit/load-dotenv.test.ts new file mode 100644 index 0000000..e796783 --- /dev/null +++ b/tests/unit/load-dotenv.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadDotEnvIfPresent } from '../../src/config/load-dotenv.js'; + +// adversarial-review P1: the quick-start docs tell a local developer to +// `cp .env.example .env`, but nothing ever loaded it — tsx/npm don't do so +// implicitly. loadDotEnvIfPresent is what every entrypoint (src/index.ts, +// scripts/migrate.ts, scripts/seed-self.ts) now calls to fix that, and it +// has to behave correctly in both directions: load the file when a local +// developer has one, and stay a no-op in production, where none exists. +describe('loadDotEnvIfPresent', () => { + const originalCwd = process.cwd(); + const VAR_NAME = 'KNOTRACK_LOAD_DOTENV_TEST_VAR'; + + afterEach(() => { + process.chdir(originalCwd); + delete process.env[VAR_NAME]; + }); + + it('positive: loads variables from a .env file in the current working directory', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'knotrack-dotenv-test-')); + try { + writeFileSync(path.join(dir, '.env'), `${VAR_NAME}=from-dotenv-file\n`, 'utf8'); + process.chdir(dir); + + expect(process.env[VAR_NAME]).toBeUndefined(); + loadDotEnvIfPresent(); + expect(process.env[VAR_NAME]).toBe('from-dotenv-file'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('negative: does not throw and leaves process.env untouched when no .env file exists (the production case)', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'knotrack-dotenv-test-empty-')); + try { + process.chdir(dir); + expect(() => loadDotEnvIfPresent()).not.toThrow(); + expect(process.env[VAR_NAME]).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/pool.test.ts b/tests/unit/pool.test.ts new file mode 100644 index 0000000..280a272 --- /dev/null +++ b/tests/unit/pool.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { createPool } from '../../src/db/pool.js'; +import type { Config } from '../../src/config/env.js'; + +// adversarial-review security-2 / data_privacy-1: rejectUnauthorized used to +// be hardcoded to false whenever SSL was required, silently accepting any +// TLS certificate. These tests pin the fixed behavior: verification is on +// by default and only the explicit opt-out env-derived config disables it. +describe('createPool TLS config (TRD §... / adversarial-review security-2)', () => { + const baseConfig: Config = { + databaseUrl: 'postgres://user:pass@localhost:5432/db', + apiTokens: ['kt_test'], + encryptionKey: Buffer.alloc(32), + nodeEnv: 'production', + port: 8080, + host: '0.0.0.0', + databaseSslMode: 'require', + dbSslRejectUnauthorized: true, + dbStatementTimeoutMs: 30000, + dbPoolMax: 10, + driftScanTrackCap: 500, + driftScanItemCap: 5000, + driftScanTimeoutMs: 5000, + roadmapTrackCap: 200, + roadmapItemPerTrackCap: 100, + staleTrackDays: 14, + nextStepsLimit: 5, + githubSyncTimeoutMs: 8000, + linearSyncTimeoutMs: 8000, + logLevel: 'info', + }; + + it('defaults to verifying the server TLS certificate when SSL is required', () => { + const pool = createPool(baseConfig); + expect(pool.options.ssl).toEqual({ rejectUnauthorized: true }); + void pool.end(); + }); + + it('only disables verification when dbSslRejectUnauthorized is explicitly false', () => { + const pool = createPool({ ...baseConfig, dbSslRejectUnauthorized: false }); + expect(pool.options.ssl).toEqual({ rejectUnauthorized: false }); + void pool.end(); + }); + + it('sets no ssl option at all when SSL mode is disable', () => { + const pool = createPool({ ...baseConfig, databaseSslMode: 'disable' }); + expect(pool.options.ssl).toBeUndefined(); + void pool.end(); + }); + + it('applies dbStatementTimeoutMs as the pg statement_timeout', () => { + const pool = createPool({ ...baseConfig, dbStatementTimeoutMs: 12345 }); + expect(pool.options.statement_timeout).toBe(12345); + void pool.end(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f24ce8c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3b950ff --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + testTimeout: 15000, + hookTimeout: 20000, + // Integration tests hit a real local Postgres and share pooled + // connections/rows via project-scoped fixtures; run files serially to + // avoid cross-test interference on the same scratch database. + fileParallelism: false, + }, +}); diff --git a/vitest.stryker.config.ts b/vitest.stryker.config.ts new file mode 100644 index 0000000..38921c1 --- /dev/null +++ b/vitest.stryker.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +// Used only by Stryker mutation testing (stryker.conf.json). Scoped to +// tests/unit — the mutated files (dependency-graph, drift-detector, +// credential-cipher, auth) are all pure/deterministic and covered there. +// Excludes tests/integration: those hit a real local Postgres and would +// make thousands of mutant runs slow and DB-contention-prone for no +// coverage benefit on these four files. +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/unit/**/*.test.ts'], + testTimeout: 15000, + hookTimeout: 20000, + }, +});