diff --git a/.changeset/capability-graph.md b/.changeset/capability-graph.md new file mode 100644 index 00000000..b5e1053c --- /dev/null +++ b/.changeset/capability-graph.md @@ -0,0 +1,23 @@ +--- +"@pracht/capabilities": minor +"@pracht/core": minor +"@pracht/vite-plugin": minor +"@pracht/cli": minor +"create-pracht": patch +--- + +The capability graph: define a typed application operation once and project it to every surface — server code, a generated HTTP endpoint, WebMCP page tools for in-browser agents, the human UI, and llms.txt discovery — with a built-in agent trust layer. See docs/CAPABILITIES.md, docs/AGENT_TRUST.md, docs/LLMS_TXT.md, and the decision log in docs/CAPABILITY_GRAPH.md. + +**Capability core.** The new `@pracht/capabilities` package provides `defineCapability()`: a protocol-neutral operation with a dependency-free JSON Schema subset validator (unsupported keywords are rejected at definition time so they can never silently widen an exposed contract), effect classes (`read`/`write`/`destructive`), named middleware, and explicit exposure. Capabilities register in the app manifest via `defineApp({ capabilities: { ... } })` and are private by default. The package is also the single home of the wire protocol — `capabilityHttpPath()`, the confirmation and transport header names, the `CapabilityErrorCode` union, the envelope types, the schema→TypeScript printer, and the shared static extractor (`@pracht/capabilities/static`) — consumed by the framework, the Vite plugin, and the CLI so the contract cannot drift between packages. Static extraction masks regex literals during entry-point discovery, including regex expression statements after control-flow conditions, and accepts ECMAScript code-point escapes based on their numeric range rather than a fixed digit count. + +Capability validation also enforces the JSON data model at every boundary, including unconstrained/additional properties and schema `const`, `default`, and `enum` values, and applies JSON Schema string lengths by Unicode code point, so multipart files, prototype-named fields, astral Unicode characters, and other JavaScript-only values cannot bypass or distort validation and destructive-call confirmation bindings. + +The shared static extractor used by browser codegen and `pracht verify` ignores comments, string contents, and regex literals when locating capability definitions and registrations, parses both fixed-width and code-point Unicode escapes in inline literals, analyzes the module's default-exported capability, and scopes manifest extraction to the exported `defineApp()` configuration — so examples, commented-out code, or a helper capability defined earlier in the file cannot change the generated capability surface. + +**Projections.** `@pracht/core` resolves the registry and runs one dispatch pipeline (input validation → named middleware → `run()` → output validation) behind every surface: request-scoped `invokeCapability()` for direct server use (loaders, API routes, middleware), `POST /api/capabilities/` with a typed `{ ok, data | error }` envelope, CSRF protection, and production redaction (custom HTTP paths that URL parsing could reinterpret as cross-origin or as a different pathname are rejected), and — via `@pracht/vite-plugin` — the generated `virtual:pracht/capabilities` browser client (`callCapability()`, with `confirm` sugar for confirmation tokens) and `virtual:pracht/webmcp`, a feature-detected WebMCP page-tool shim (`document.modelContext.registerTool`, Chrome origin trial). Direct invocation hosts are bound to their incoming `Request`, so overlapping apps or dev-server generations cannot route a call through another registry. Both virtual modules cost zero bytes when unused. + +**One contract for humans and agents.** `
` posts the framework's form component straight to the capability endpoint agents call: fields are coerced onto the input schema server-side, `onCapabilityResult` receives the typed envelope, and without JavaScript the endpoint accepts the form-encoded post and answers a successful document submission with a 303 back to the same-origin referring page. Enhanced submissions honor a clicked submitter's `formaction` and follow middleware redirects to their final browser URL, matching that no-JavaScript behavior: a redirect is handed back to the same-origin fetch as a readable target (with relative `Location` values resolved against the endpoint) and the browser navigates itself, so an external OAuth/SSO destination is never fetched through CORS and never submitted twice, and a cross-origin form target falls back to a native document submission (after client-side schema validation, if any). Effect classes drive the client cache: after any successful non-`read` browser call (`callCapability()` or ``) the active route's loader data revalidates automatically — a full reload under islands hydration — and `revalidate: false` opts out per call. + +**Agent trust layer.** Web Bot Auth verification (RFC 9421 HTTP Message Signatures, Ed25519 via WebCrypto, static keys or allowlisted `/.well-known/http-message-signatures-directory` JWKS lookups — fail closed everywhere) opts in via `defineApp({ agents: { webBotAuth } })` and surfaces the verified identity as `context.agent` — now typed end to end (`CapabilityContext`, `PrachtRequestContext`) with `"observe"`/`"require"` policies and per-capability `agentPolicy` overrides. Destructive capabilities may expose over HTTP only, gated by a server-verified prepare/commit confirmation flow (`409 confirmation_required` + short-lived HMAC token bound to principal, capability, and canonical input; requires `PRACHT_CONFIRMATION_SECRET`). The gate runs inside the named middleware chain, so rate limiting sees prepare and invalid-token attempts too. Every dispatch emits a structured audit event (`setCapabilityAuditHook()` / `onCapabilityAudit`) whose transport distinguishes `http`, `server`, and `webmcp`. + +**Discovery & DX.** The opt-in `pracht({ llmsTxt })` option emits llms.txt (https://llmstxt.org) from the resolved app graph — pages, API endpoints, and HTTP-exposed capabilities with effect classes — written at build time and served live in dev; `create-pracht` templates enable it by default. `pracht typegen` emits `src/pracht-capabilities.d.ts` so `invokeCapability()`, `callCapability()`, ``, and the test host infer input/output types from the capability name. `pracht eval` runs scripted agent-task scenarios (with `$steps[n]` references and a `confirm` field for the confirmation flow) against a live app, `--start` managing the server lifecycle. `createCapabilityTestHost()` unit-tests the full pipeline including simulated agent identities. `pracht inspect capabilities`, the MCP `inspect_capabilities` tool, `/_pracht` devtools, and the dev banner all render the same graph — with declared-but-unserved `expose.mcp` labeled `mcp(unserved)` and warned about by `pracht verify` until the remote MCP projection ships. diff --git a/.oxfmtrc.json b/.oxfmtrc.json index e139f85d..6df33280 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,11 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["**/*.md", "**/package.json", "**/LICENSE"] + "ignorePatterns": [ + "**/*.md", + "**/package.json", + "**/LICENSE", + "**/pracht.d.ts", + "**/pracht-routes.ts", + "**/pracht-capabilities.d.ts" + ] } diff --git a/README.md b/README.md index c05cfa4b..57e904e3 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ The starter gives you: - `pracht dev` — local SSR + HMR, a `/_pracht` devtools page with the resolved route/API graph (JSON at `/_pracht.json`), and `Server-Timing` middleware/loader/render phase timings on every dev SSR response - `pracht build` — client/server output plus SSG/ISG prerendering, with `--analyze` for a per-route client JS report and budget enforcement - `pracht preview` — build and serve the production build locally -- `pracht inspect [routes|api|build] --json` — resolved app graph metadata +- `pracht inspect [routes|api|capabilities|build] --json` — resolved app graph metadata - `pracht generate route|shell|middleware|api` — framework-native scaffolding; `generate route` also emits a Playwright smoke test when the app has an e2e setup - `pracht verify` — fast framework-aware checks with `--changed` and `--json`, including `defineApp({ constraints })` enforcement and app-graph snapshot freshness - `pracht plan` — semantic app-graph diff against a base git ref (`--write` refreshes the committed `.pracht/app-graph.json` snapshot; `--markdown` for PR comments) @@ -107,6 +107,9 @@ Pracht is built to be operated by coding agents as much as by humans — and for - **Machine-enforced invariants** — `defineApp({ constraints })` declares rules like `requireMiddleware("/app/**", "auth")` that `pracht verify` enforces deterministically, so no author (human or LLM) can merge a violation. - **MCP server** — `pracht mcp` starts a stdio [Model Context Protocol](https://modelcontextprotocol.io) server so agents can natively inspect the resolved app graph, run doctor/verify diagnostics, diff and snapshot the graph (plan/report), read the authoring guide (get_docs), and scaffold routes, shells, middleware, and API handlers. See [docs/MCP.md](docs/MCP.md) for registration and the tool reference. - **Authoring guide for agents** — `pracht llms --write` drops the framework's conventions into `llms.txt` so any coding agent picks them up. +- **Capabilities & WebMCP** — `@pracht/capabilities` lets you define a typed application operation once (JSON Schema contract, effect class, middleware) and project it to server code, a generated HTTP endpoint, a WebMCP page tool for in-browser agents, and the human UI via `` — private by default, with `pracht verify` enforcing the security defaults and effect-driven revalidation keeping the page consistent after mutations. See [docs/CAPABILITIES.md](docs/CAPABILITIES.md). +- **Agent trust layer** — Web Bot Auth (RFC 9421) verified agent identity on the request context with observe/require policies, a prepare/commit confirmation flow for destructive capabilities, capability audit events, and `pracht eval` for scripted agent-task checks in CI. See [docs/AGENT_TRUST.md](docs/AGENT_TRUST.md). +- **llms.txt** — the opt-in `llmsTxt` plugin option emits an [llms.txt](https://llmstxt.org) index of your pages, API routes, and HTTP-exposed capabilities at build time and serves it live in dev. See [docs/LLMS_TXT.md](docs/LLMS_TXT.md). - **Claude Code skills** — 28 skills for scaffolding, auditing, testing, debugging, and deploying pracht apps live in [skills/](skills/README.md). See the [agent skills](#agent-skills) section below. ## Agent skills @@ -116,7 +119,6 @@ The skills are distributed three ways ([docs](https://pracht.resynapse.dev/docs/ - **Discovery endpoint** — every skill is published at `https://pracht.resynapse.dev/skills//SKILL.md`, listed with SHA-256 digests in the manifest at [`/.well-known/agent-skills/index.json`](https://pracht.resynapse.dev/.well-known/agent-skills/index.json) and advertised via a `Link: rel="agent-skills"` header. - **create-pracht** — `npm create pracht@latest` seeds the full catalog into new apps' `.claude/skills/` and writes a `.mcp.json` registering the `pracht mcp` server (yes-default prompt, `--no-agent-tools` to skip). - **In this repo** — `.claude/skills` symlinks to [skills/](skills/README.md), so Claude Code loads them automatically for contributors. ->>>>>>> 463a134 (Ship the skills to users: create-pracht seeding, docs page, contributor loading) ## Repo map @@ -127,6 +129,10 @@ The skills are distributed three ways ([docs](https://pracht.resynapse.dev/docs/ - [docs/PERFORMANCE.md](docs/PERFORMANCE.md) — bundle analysis and per-route client JS budgets - [docs/DATA_LOADING.md](docs/DATA_LOADING.md) — loaders, forms, client hooks - [docs/API_VALIDATION.md](docs/API_VALIDATION.md) — Standard Schema validation for API routes, typed `apiFetch()` +- [docs/CAPABILITIES.md](docs/CAPABILITIES.md) — typed capabilities, HTTP projection, WebMCP page tools +- [docs/AGENT_TRUST.md](docs/AGENT_TRUST.md) — Web Bot Auth, effect-class confirmation flow, audit hook, `pracht eval` +- [docs/CAPABILITY_GRAPH.md](docs/CAPABILITY_GRAPH.md) — the capability graph product bet: proposal, decision log, staged plan +- [docs/LLMS_TXT.md](docs/LLMS_TXT.md) — generated llms.txt: pages, API endpoints, capabilities - [docs/STYLING.md](docs/STYLING.md) — CSS Modules, Tailwind, CSS-in-JS limitations - [docs/ADAPTERS.md](docs/ADAPTERS.md) — Node, Cloudflare, Vercel deployment paths - [docs/MCP.md](docs/MCP.md) — built-in MCP server for coding agents diff --git a/VISION_MVP.md b/VISION_MVP.md index 47b2988b..f0b0504a 100644 --- a/VISION_MVP.md +++ b/VISION_MVP.md @@ -119,6 +119,40 @@ Standalone server endpoints independent of the page rendering pipeline: compile time and returns typed responses. See [docs/API_VALIDATION.md](docs/API_VALIDATION.md). +### Capabilities (agent-ready operations) + +Typed, protocol-neutral application operations registered in the manifest via +`defineApp({ capabilities: { ... } })` and defined with `defineCapability()` +from `@pracht/capabilities`: + +- One contract (JSON Schema input/output, effect class, named middleware, + server-only `run()`), projected to direct server invocation + (`invokeCapability()`), a generated HTTP endpoint (`expose.http`), and a + WebMCP page tool for in-browser agents (`expose.webmcp`). +- The same contract serves the human UI: `` posts to the + capability endpoint (with a no-JS form-encoded fallback), and successful + non-`read` browser calls revalidate route data automatically — the effect + class drives the client cache. The wire protocol (paths, headers, error + codes, envelope) has a single home in `@pracht/capabilities`. +- Private by default; exposure requires a complete contract, `destructive` + effects are HTTP-only behind a server-verified prepare/commit confirmation + flow, and the graph feeds `pracht inspect capabilities`, `/_pracht`, + `pracht verify`, and the CLI MCP server. +- Agent trust layer: Web Bot Auth (RFC 9421) verified agent identity as + `context.agent` with observe/require policies, capability audit events, + and a `pracht eval` harness for scripted agent-task checks. +- Discovery: the opt-in `llmsTxt` plugin option emits an + [llms.txt](https://llmstxt.org) index from the app graph — pages, API + endpoints, and HTTP-exposed capabilities + ([docs/LLMS_TXT.md](docs/LLMS_TXT.md)). +- `pracht typegen` generates capability input/output types from the graph, so + `invokeCapability()` and the browser's `callCapability()` infer both sides + from the capability name. +- See [docs/CAPABILITIES.md](docs/CAPABILITIES.md) and + [docs/AGENT_TRUST.md](docs/AGENT_TRUST.md). The product bet, its decision + log, and the staged plan (remote MCP and MCP Apps are the unbuilt stages) + live in [docs/CAPABILITY_GRAPH.md](docs/CAPABILITY_GRAPH.md). + ### Deployment Adapters Platform adapters export a request handler shaped for their runtime: diff --git a/docs/AGENT_TRUST.md b/docs/AGENT_TRUST.md new file mode 100644 index 00000000..5c053008 --- /dev/null +++ b/docs/AGENT_TRUST.md @@ -0,0 +1,324 @@ +# Agent Trust Layer + +The agent trust layer answers three questions about the capability graph +(see [CAPABILITIES.md](CAPABILITIES.md)): + +- **Who is calling?** — Web Bot Auth verification puts a cryptographically + verified agent identity on the request context. +- **May they do this?** — policy modes per app and per capability, plus a + server-verified prepare/commit confirmation flow for destructive + capabilities. +- **What happened?** — a structured audit event for every capability + dispatch, and `pracht eval` to test agent task flows in CI. + +Everything is opt-in and zero-cost when unused: an app without +`defineApp({ agents })` and without destructive capabilities pays a single +property check per request. + +## Web Bot Auth: verified agent identity + +Web Bot Auth is the emerging standard (implemented by major CDNs) where an +agent signs its requests with [RFC 9421 HTTP Message +Signatures](https://www.rfc-editor.org/rfc/rfc9421) and publishes its public +keys in a well-known directory. Pracht implements the verifier side of: + +- [draft-meunier-web-bot-auth-architecture-02](https://www.ietf.org/archive/id/draft-meunier-web-bot-auth-architecture-02.html) + — the protocol: covered components, signature parameters, the + `web-bot-auth` tag; +- [draft-meunier-http-message-signatures-directory-03](https://www.ietf.org/archive/id/draft-meunier-http-message-signatures-directory-03.html) + — key discovery: an Ed25519 JWKS at + `/.well-known/http-message-signatures-directory`, `keyid` as the RFC + 7638/8037 JWK SHA-256 thumbprint. + +A signed agent request carries three headers: + +```text +Signature-Agent: "https://signature-agent.example" +Signature-Input: sig1=("@authority" "signature-agent");created=1735689600; + expires=1735693200;keyid="poqkLGiy...";alg="ed25519"; + nonce="...";tag="web-bot-auth" +Signature: sig1=:jdq0SqOwHdyHr9+r5jw3iYZH6aNGKijYp/EstF4RQTQ=: +``` + +### Configuration + +Verification lives in `defineApp({ agents })` — the same manifest seam as +shells, middleware, and capabilities, and (like them) serializable data only. +Web Bot Auth keys are *public* keys, so they are safe in the manifest even +though the manifest is bundled into the client: + +```ts +export const app = defineApp({ + agents: { + webBotAuth: { + policy: "observe", // app-wide default + keys: [ + // Statically pinned agents (tests, air-gapped deploys). + { x: "", agent: "my-agent.example" }, + ], + // Origins whose key directory may be fetched (allowlist-only). + directories: ["https://signature-agent.cloudflare.com"], + clockSkewSeconds: 60, // default + maxLifetimeSeconds: 86_400, // default, per draft guidance + directoryCacheTtlSeconds: 300, + }, + }, + // capabilities, routes, ... +}); +``` + +The runtime (`handlePrachtRequest`) verifies once per request — all adapters +(Node, Cloudflare, Vercel) share the implementation because it only uses Web +platform APIs (`Headers`, `fetch`, `crypto.subtle`; Ed25519 works on Node ≥ +20, Workers, and Vercel Edge). The result surfaces on the request context for +middleware, loaders, API routes, and capability `run()`: + +```ts +async run({ context }) { + context.agent; // { verified: true, agentDomain, keyId } | null +} +``` + +`context.agent` is only set when `agents.webBotAuth` is configured; it is +`null` for unsigned or unverifiable requests. + +### Verification rules (fail closed) + +A signature verifies only when **all** of the following hold; any failure +yields `context.agent = null`, never a partial identity: + +- `Signature-Input`/`Signature` parse as RFC 8941 structured fields and the + member's `tag` is `web-bot-auth`; +- covered components include `@authority` (and `signature-agent` whenever the + header is present, per the draft); +- `created`/`expires` are present, `created ≤ now ≤ expires` within the + configured clock skew, and the lifetime is within `maxLifetimeSeconds`; +- `alg`, when present, is `ed25519`; +- the `keyid` resolves to a trusted key: a configured static key, or a key in + the agent's directory — fetched only when the `Signature-Agent` origin is + explicitly allowlisted in `directories` (https only, redirects refused, + 64 KB response cap, 5 s timeout, in-memory TTL cache). No allowlist means + no fetching — this is deliberate: open directory fetching would let any + request body point your server at attacker-controlled URLs (SSRF); +- the Ed25519 signature verifies over the RFC 9421 signature base via + WebCrypto. + +For statically pinned keys, `context.agent.agentDomain` is the configured +`agent` label (or `null` when omitted), even if the signed request also sends +`Signature-Agent`. The header's host is used only for keys resolved from an +allowlisted directory. + +Replay note: the drafts allow enforcing `nonce` uniqueness with a store; +Pracht's stateless verifier does not (a signature can be replayed against +the same authority until it expires). Bind short `expires` windows and treat +the identity as *authentication*, not as a per-request authorization grant. + +### Policy modes + +- `"observe"` (default) — identify agents, serve everyone. Use it to roll + out and to audit who is calling. +- `"require"` — unsigned or unverified requests to **capability HTTP + endpoints** receive the typed `401 { error: { code: "agent_required" } }` + envelope. Pages and API routes are not gated (use `context.agent` in + middleware for those). + +The app default can be overridden per capability: + +```ts +export default defineCapability({ + // ... + agentPolicy: "require", // this endpoint answers only verified agents +}); +``` + +`agentPolicy: "require"` fails closed even when `webBotAuth` is not +configured (every request would be 401 — a loud misconfiguration signal). + +## Effect classes and the confirmation flow + +Every capability declares `read`, `write`, or `destructive` +([CAPABILITIES.md](CAPABILITIES.md#effects)). Destructive capabilities: + +- **may set `expose.http`** — every dispatch is gated by the prepare/commit + flow below, and only when a confirmation secret is configured; +- **may not set `expose.webmcp` or `expose.mcp` (v1)** — host-side approval + UX is not a security boundary, and agent hosts cannot yet be trusted to + carry the two-step flow faithfully; `defineCapability()`, the registry, and + `pracht verify` all reject it. + +### Prepare/commit + +Set `PRACHT_CONFIRMATION_SECRET` in the server environment (or call +`setCapabilityConfirmationSecret()` from server code on platforms without +`process.env`). Without it, destructive HTTP calls fail closed with +`403 confirmation_unavailable`, and `pracht verify` fails. + +1. **Prepare** — a call without a token never runs the capability: + + ```jsonc + // POST /api/capabilities/notes/purge { "titlePrefix": "Old" } + // → 409 + { + "ok": false, + "error": { + "code": "confirmation_required", + "message": "…repeat the call with identical input and the x-pracht-confirm header…", + "confirmationToken": "v1..", + "expiresAt": 1735689720 + } + } + ``` + + The token is an HMAC-SHA256 (WebCrypto) over the caller's principal + (verified agent `keyid`, or `"anonymous"`), the capability name, a hash of + the canonicalized validated input (stable JSON, sorted keys, defaults + applied), and an expiry (TTL default 120 s, configurable via + `agents.confirmation.ttlSeconds`). + +2. **Commit** — repeat the call with byte-identical canonical input plus the + `x-pracht-confirm` header. The server re-derives the binding and runs the + capability only if everything matches. Tampered, expired, + different-input, or different-principal tokens → `403 + confirmation_invalid`, fail closed. + +### Honest limitations + +- **Stateless HMAC cannot prevent replay within the TTL.** A captured token + authorizes the same principal + capability + input until it expires. + `agents.confirmation.singleUse: true` enables a best-effort in-memory + cache — per instance, lost on restart, not shared across replicas. True + single-use needs shared storage (deliberately out of scope for v1). +- **Principal binding is only as strong as the principal.** Without Web Bot + Auth (or your own auth middleware), both prepare and commit run as + `"anonymous"` — the flow still forces the two-step round trip with + identical input, but does not tie the token to a caller. + +## Operational hardening: what the framework does not do (yet) + +The capability pipeline enforces contracts, policy, and confirmation. Three +operational concerns deliberately stay outside it for now — treat them as +deployment responsibilities, not solved problems: + +- **Rate limiting.** There are no built-in per-principal or per-capability + limits. Put rate limiting in the capability's named middleware: it runs + before `run()` on every projection (HTTP and direct invocation), sees + `context.agent` when Web Bot Auth is enabled, and can short-circuit with a + 429 response. The audit hook provides per-capability outcome and latency + data to alert on. +- **Write idempotency.** `write` capabilities have no framework idempotency + helper. Agents retry, and confirmation tokens only gate `destructive` + effects — so design write inputs to be safely repeatable: accept a + client-supplied idempotency key in the input schema, or deduplicate inside + `run()`. +- **Result-size limits.** Request body limits belong to the adapter; there is + no output-size budget on capability results. Keep outputs bounded (a `limit` + input with a schema `maximum`, pagination) — oversized results hurt agents + (context windows) and browsers alike. + +## Audit trail + +Every capability dispatch — HTTP or direct `invokeCapability()` — emits one +structured event: + +```ts +interface CapabilityAuditEvent { + capability: string; // "notes.purge" + effect: "read" | "write" | "destructive"; + transport: "http" | "server" | "webmcp"; + outcome: string; // "ok" | "invalid_input" | "confirmation_required" | ... + status: number; + durationMs: number; + agent: { verified: true; agentDomain: string | null; keyId: string } | null; +} +``` + +`"webmcp"` reflects the transport marker header +(`CAPABILITY_TRANSPORT_HEADER` from `@pracht/capabilities`) that the +generated WebMCP shim sends with its dispatches, so audit trails can tell +in-browser agent traffic (cookie-authenticated) apart from remote HTTP +callers. Like any client-sent header it is informational, not a trust +signal. `outcome` values come from the `CapabilityErrorCode` union exported +by `@pracht/capabilities` (plus `"ok"` and middleware short-circuits). + +Subscribe from any server-only module (audit hooks observe: exceptions are +swallowed, never breaking a request): + +```ts +import { setCapabilityAuditHook } from "@pracht/core"; + +setCapabilityAuditHook((event) => log.info("capability", event)); +``` + +Custom server entries can also pass `onCapabilityAudit` directly to +`handlePrachtRequest()`; both hooks fire. + +## `pracht eval`: scripted agent-task scenarios + +`pracht eval [files...]` runs JSON scenarios against a live app's capability +HTTP projection and exits 1 on any failed expectation — "can an agent +actually complete this task through my tools?" as a repeatable CI check. + +```bash +pracht eval --start "pracht preview" # starts the app, runs evals/**/*.eval.json, stops it + +pracht preview # …or manage the server yourself, in another terminal +pracht eval --url http://localhost:3000 # runs evals/**/*.eval.json +pracht eval evals/notes.eval.json --json # explicit files, CI output +``` + +`--start ""` spawns the command in its own process group, polls +`--url` (default `http://localhost:3000`) until the server answers (30s +timeout, any HTTP response counts), runs the scenarios, and stops the whole +group afterwards. On timeout or early exit it prints the command's output and +exits 1. + +Scenario format (`examples/basic/evals/notes.eval.json` is a working +example): + +```jsonc +{ + "name": "notes agent flow", + "task": "optional human description", + "url": "http://localhost:3000", // optional; --url overrides + "steps": [ + { + "capability": "notes.purge", // name → POST /api/capabilities/notes/purge + "path": "/api/custom", // optional override for custom expose.http.path + "input": { "titlePrefix": "Old" }, + "confirm": "$steps[0].error.confirmationToken", // sets the confirmation header + "expect": { + "ok": false, // envelope ok flag + "status": 409, // HTTP status + "errorCode": "confirmation_required", // envelope error.code + "output": { "purged": 1 } // deep subset match on data + } + } + ] +} +``` + +- Steps run in order. A step without `expect` must simply succeed + (`ok: true`). +- **References**: a string value that is exactly + `$steps[].` resolves against an earlier step's result + object `{ status, ok, data, error }` — e.g. + `$steps[0].error.confirmationToken` to carry the confirmation flow, or + `$steps[1].data.note.id`. References work anywhere in `input`, `headers`, + and `confirm`; unresolvable references fail the scenario. +- **Confirmation flow**: `confirm` sets the confirmation header without + spelling out its name; raw `headers` still work for anything else. +- Output: a human transcript (step, capability, outcome, status, latency, + denial reasons) or `--json` for CI. + +## Not built yet + +- Directory fetching without an allowlist (needs an SSRF story). +- `nonce` uniqueness enforcement and shared-storage single-use confirmation + tokens. +- RSA-PSS agent keys (the Web Bot Auth ecosystem is Ed25519-first). +- Destructive capabilities over WebMCP/MCP, and `pracht eval` speaking MCP + instead of the HTTP projection. +- Framework-level rate limiting, write-idempotency helpers, and result-size + limits — see [operational + hardening](#operational-hardening-what-the-framework-does-not-do-yet) for + the middleware/app-level approach in the meantime. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md new file mode 100644 index 00000000..ae3b6ab7 --- /dev/null +++ b/docs/CAPABILITIES.md @@ -0,0 +1,360 @@ +# Capabilities + +Capabilities are typed, protocol-neutral application operations: one explicit +contract (JSON Schema input/output, an effect class, named middleware, a +server-only `run()` function) that Pracht can project into multiple surfaces. +Today those surfaces are: + +- **direct server invocation** — `invokeCapability()` from loaders, API + routes, and middleware; +- **an HTTP endpoint** — generated `POST` dispatch when `expose.http` is set; +- **a WebMCP page tool** — registered in the browser for in-page agents when + `expose.webmcp` is set (Chrome origin trial). + +Every projection calls the same validated pipeline, so business rules never +diverge between transports: + +```text +input validation → named middleware chain → run() → output validation +``` + +Capability inputs, outputs, and schema values are restricted to the JSON data +model. JavaScript-only values such as `File`, `Blob`, `Date`, `Map`, +`undefined`, and circular objects are rejected even when a schema is otherwise +unconstrained. File uploads should stay in API routes rather than capability +contracts. + +## Registration + +Capabilities are registered in the app manifest, exactly like shells and +middleware. Registration is deliberately opt-in: no API route or loader is +ever inferred as a capability. + +```ts +// src/routes.ts +import { defineApp } from "@pracht/core"; + +export const app = defineApp({ + capabilities: { + "notes.search": () => import("./capabilities/notes-search.ts"), + "notes.create": () => import("./capabilities/notes-create.ts"), + }, + // shells, middleware, routes... +}); +``` + +Capability modules live in `src/capabilities/` by default (configurable via +the `capabilitiesDir` plugin option). Names are dot-separated segments of +letters, numbers, hyphens, and underscores. Capabilities are manifest-mode +only for now — the pages router has no manifest to register them in. + +## defineCapability + +```ts +// src/capabilities/notes-search.ts +import { defineCapability } from "@pracht/capabilities"; +import { searchNotes } from "../server/notes-store.ts"; + +export default defineCapability({ + title: "Search notes", + description: "Find notes whose title or body matches the query.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array", items: { type: "object" } } }, + required: ["notes"], + }, + effect: "read", + middleware: ["auth"], // optional — names from the app manifest + expose: { http: true, webmcp: true }, // optional — private without it + async run({ input, context, request, signal }) { + return { notes: searchNotes(input.query, input.limit) }; + }, +}); +``` + +`context` defaults to `CapabilityContext`: `context.agent` is typed as the +verified Web Bot Auth identity (`PrachtAgentIdentity | null`, absent when +`agents` is not configured — see [AGENT_TRUST.md](AGENT_TRUST.md)), and +everything middleware attaches is reachable as `unknown`. Narrow it with your +own type via the third generic (`defineCapability`), or +use the framework's `PrachtRequestContext` for the app-registered context. + +### Schemas + +`input` and `output` are plain JSON Schema objects validated by a +dependency-free subset validator (no ajv/zod in your server or client +bundles). Supported keywords: + +`type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`), +`properties`, `required`, `additionalProperties`, `items` (single schema), +`enum`, `const`, `minimum`, `maximum`, `minLength`, `maxLength`, `default` +(applied to input before validation), plus the `title` and `description` +annotations. + +Anything else — `oneOf`, `anyOf`, `allOf`, `$ref`, `pattern`, `format`, +tuple-form `items`, array `type` unions, and the rest of full JSON Schema — +is **rejected**: `defineCapability()` throws at definition time and +`pracht verify` fails, naming the offending keyword. A keyword the validator +would silently ignore could otherwise widen what an exposed capability +accepts. + +Validation errors are path-scoped (`{ path: "/limit", message: "must be <= 20" }`) +so humans and agents can pinpoint what to fix. + +### Effects + +Every capability declares one of `read`, `write`, or `destructive`. +Destructive capabilities (delete, publish, pay, send, change access) may be +exposed over HTTP only, and every dispatch is gated by a server-verified +prepare/commit confirmation flow that requires `PRACHT_CONFIRMATION_SECRET` +to be configured — see [AGENT_TRUST.md](AGENT_TRUST.md). Exposing them to +agent projections (`expose.webmcp`/`expose.mcp`) stays disallowed: +`defineCapability()`, the runtime registry, and `pracht verify` all enforce +this. + +## Invocation + +### Server-side + +```ts +import { invokeCapability } from "@pracht/core"; + +export async function loader({ request, context, signal }: LoaderArgs) { + const result = await invokeCapability<{ notes: Note[] }>( + "notes.search", + { query: "roadmap" }, + { request, context, signal }, + ); + if (!result.ok) { + // result.error: { code, message, issues? } + } + return result.ok ? result.data : { notes: [] }; +} +``` + +Direct invocation works for private capabilities too and runs the full +pipeline, including the capability's middleware. It is available while +`handlePrachtRequest()` is serving requests (loaders, API routes, +middleware). + +### HTTP projection + +With `expose.http` set, the capability is dispatched at +`POST /api/capabilities/` (e.g. `notes.search` → +`/api/capabilities/notes/search`), or at a custom `expose.http.path`. Dispatch +happens in the framework's request handler, so every adapter (Node, +Cloudflare, Vercel) gets it without adapter changes. Explicit files in +`src/api/` take precedence on path collisions. + +Custom paths must be exact same-origin pathnames beginning with `/`. Protocol- +relative URLs, backslashes, dot-segment normalization, query strings, and +fragments are rejected so the generated browser client can never reinterpret +an endpoint as cross-origin. + +Requests and responses use a typed envelope: + +```jsonc +// 200 +{ "ok": true, "data": { ... } } +// 400 invalid input (path-scoped issues), 401/403 from middleware, +// 404 unknown capability, 405 non-POST, 500 internal +{ "ok": false, "error": { "code": "invalid_input", "message": "...", "issues": [ ... ] } } +``` + +Internal error details and output-schema violations are redacted in +production; invalid `run()` output is always a 500 and never returned raw. +State-changing capability calls enforce the same same-origin CSRF policy as +API routes (`api.requireSameOrigin`, on by default). + +The wire contract has one home: `@pracht/capabilities` exports the path +formula (`capabilityHttpPath`), the confirmation, transport, effect, and +enhanced-form redirect header names, the envelope types, and the full +`CapabilityErrorCode` union (`CAPABILITY_ERROR_CODES`) — the framework +runtime, the generated client modules, and the CLI all import from it, so the +protocol cannot drift between packages. + +### Browser + +```ts +import { callCapability } from "virtual:pracht/capabilities"; + +const result = await callCapability<{ note: Note }>("notes.create", { title }); +``` + +`virtual:pracht/capabilities` is generated at build time from the manifest and +contains only http-exposed capability names, endpoints, and effect classes — +capability modules themselves are server-only and never enter the client graph +(guarded by e2e bundle assertions). Apps without capabilities ship zero extra +bytes. + +Options: `{ headers, signal, confirm, revalidate }`. `confirm` sets the +confirmation header when committing a destructive call (take the token from +the prior `confirmation_required` envelope). After a successful non-`read` +call the active route's data revalidates automatically — the effect class the +capability already declares drives the client cache; pass `revalidate: false` +to opt a call out. + +### Forms + +The framework's `` posts directly to a capability, so the human form +and the agent tool literally share one contract: + +```tsx +import { Form } from "@pracht/core"; + + { ... }}> + + +; +``` + +- Fields are coerced onto the capability's input schema server-side (numbers + parsed, checkbox `on` → boolean, repeated fields → arrays), then validated + like any other call. +- After a successful submission the route's loader data revalidates + automatically; `onCapabilityResult` receives the typed envelope (inferred + from the capability name once typegen has run). +- Progressive enhancement: without JavaScript the endpoint accepts the + form-encoded post and answers a successful document submission with a 303 + back to the same-origin referring page. Failed posts keep the JSON envelope. +- For capabilities with a custom `expose.http.path`, set `action` explicitly. +- Submit buttons can override that target with `formaction`; enhanced and + no-JavaScript submissions resolve the same endpoint. +- Redirects returned by capability middleware (for example, an authentication + redirect to a login page) navigate normally in enhanced forms, including + cross-origin OAuth/SSO destinations. Pracht returns the redirect target to + the same-origin form fetch and lets the browser navigate, so the external + page is never fetched through CORS. Relative `Location` values resolve + against the capability endpoint, matching native HTTP redirect behavior. + +## Generated types + +`pracht typegen` (the same command that generates typed routes) also emits +`src/pracht-capabilities.d.ts` when the app registers capabilities: TypeScript +input/output types generated from each capability's JSON Schemas, registered +on `Register["capabilities"]`. With that file in the program, +`invokeCapability()`, the browser's `callCapability()`, and +`createCapabilityTestHost().invoke()` all infer both sides from the capability +name — no per-call generics: + +```ts +const result = await invokeCapability("notes.search", { query: "roadmap" }, args); +// result.data: { notes: Array<...> } — inferred from the output schema +``` + +- An input property is optional when it is not `required` **or** declares a + schema `default` (defaults are applied before input validation); an output + property is optional exactly when it is not `required`. +- Objects without `additionalProperties: false` keep an index signature, so + extra members remain reachable as `unknown`. +- Unregistered names — and a mismatched input on a registered name — fall back + to the untyped `invokeCapability(name, ...)` overload; runtime + validation still rejects bad input either way. +- `--capabilities-out` overrides the output path, `--check` covers the file in + CI, and removing the last capability rewrites an existing file to the empty + registration instead of leaving it stale. + +## WebMCP + +With `expose.webmcp: true` (which requires `expose.http`), the client runtime +registers the capability as a WebMCP page tool for in-browser agents. The +shim targets the Chrome origin-trial API — `document.modelContext.registerTool()` +(Chrome 150+, with the deprecated `navigator.modelContext` as a fallback): + +- one tool per capability: `name`, `description`, `inputSchema` (the + capability's JSON Schema), `annotations.readOnlyHint` from the effect; +- `execute()` calls the HTTP projection via `callCapability`, so the user's + session authenticates the call and validation, middleware, and policy all + stay server-side — the agent acts as the signed-in user, in their tab; +- the shim lives in its own chunk (`virtual:pracht/webmcp`) behind feature + detection: browsers without the API never download it, and pages without + webmcp-exposed capabilities never reference it; +- works in full-hydration and islands modes (the islands bootstrap pulls the + shim in too; `hydration: "none"` pages ship no JS and register no tools). + +If WebMCP does not graduate from its origin trial, the shim is deletable +without touching the capability contract. + +### Build-time extraction constraint + +The browser modules are generated by static analysis: a capability's `expose`, +the `effect` of every HTTP-exposed capability, and (for webmcp-exposed +capabilities) `input` must be **inline literals** — not imported constants or +spreads. `effect` must be an inline `"read"`, `"write"`, or `"destructive"` +string; `expose` and `input` must be inline object literals (primitive or array +`expose` values are invalid). Violations fail the build with a pointer to the +file, and `pracht verify` reports the same projection constraints. + +## Security defaults + +- **Private by default** — a capability without `expose` is never reachable + over the network. +- **Exposure requires a complete contract** — `pracht verify` fails for + exposed capabilities missing a description, input schema, output schema, or + effect classification. +- **`destructive` is confirmation-gated** — HTTP exposure requires the + prepare/commit confirmation flow (and its secret); `webmcp`/`mcp` exposure + is an error. See [AGENT_TRUST.md](AGENT_TRUST.md). +- **Verified agent identity and policy** — Web Bot Auth (RFC 9421) puts + `context.agent` on every request when enabled; capability endpoints can + `agentPolicy: "require"` verified agents, and every dispatch emits an + audit event. See [AGENT_TRUST.md](AGENT_TRUST.md). +- **Output is validated** — a handler returning data outside its output + schema produces a redacted 500, never the raw value. +- **Same-origin enforcement** — cross-origin browser POSTs are rejected by + default, matching API-route CSRF policy. +- **Fail closed** — a capability registry that cannot resolve (bad module, + duplicate paths, unknown middleware) answers capability requests with 500 + and never partially serves. + +## Inspection + +The capability graph feeds every existing inspection surface: + +- the `pracht dev` startup banner prints a Capabilities table (name, effect, + exposure, dispatch path) whenever the app registers any; +- `pracht inspect capabilities [--json]` — name, effect, transports, HTTP + path, middleware, source, plus the input/output JSON Schemas in `--json` + output; +- the `/_pracht` devtools page gains a Capabilities table (dev only, rendered + only when capabilities exist); +- the `pracht mcp` server exposes an `inspect_capabilities` tool; +- `pracht verify` runs the static contract checks described above. + +## Testing agent flows + +`createCapabilityTestHost()` (from `@pracht/core`) runs the dispatch pipeline +in-process for unit tests — no manifest, no Vite, no server. `invoke()` +mirrors `invokeCapability()`; `request()` mirrors the HTTP projection, +including Web Bot Auth policy (inject a simulated identity via the `agent` +option) and the destructive prepare/commit confirmation flow (set +`PRACHT_CONFIRMATION_SECRET` or call `setCapabilityConfirmationSecret()` in +test setup). See `packages/framework/test/capability-test-host.test.ts` for +worked examples. + +`pracht eval` runs scripted scenarios (search → validation failure → +confirmation flow) against the capability HTTP projection and exits 1 on any +failed expectation — `--start ""` launches and stops the app itself. +See [AGENT_TRUST.md](AGENT_TRUST.md#pracht-eval-scripted-agent-task-scenarios) +and `examples/basic/evals/notes.eval.json`. + +## Not built yet + +- Remote MCP projection (`/mcp` Streamable HTTP endpoint) and `expose.mcp` + (accepted and recorded in the graph, but nothing serves it yet — + `pracht verify` warns and the dev banner shows it as `mcp(unserved)` so a + declared-but-dead transport is never mistaken for a live one). +- MCP Apps UI (`ui` option) — `hasUi` is always `false` in the graph. +- Destructive capabilities over WebMCP/MCP (HTTP-only, confirmation-gated — + see [AGENT_TRUST.md](AGENT_TRUST.md)). +- Capability scaffolding (`pracht generate capability`). +- Pages-router support. diff --git a/docs/CAPABILITY_GRAPH.md b/docs/CAPABILITY_GRAPH.md new file mode 100644 index 00000000..4aec40c8 --- /dev/null +++ b/docs/CAPABILITY_GRAPH.md @@ -0,0 +1,791 @@ +# Pracht Capability Graph + +**Status:** Accepted — Stage 1, Stage 2b, and the trust layer shipped; see the +[decision log](#decision-log-2026-07-11) + +**Date:** 2026-07-10 (decision log added 2026-07-11) + +**Recommendation:** Make typed, protocol-neutral application capabilities the next major Pracht +primitive. + +> The code samples below are the original proposal's illustrations and predate +> the implementation. The shipped developer model is documented in +> [CAPABILITIES.md](CAPABILITIES.md) and [AGENT_TRUST.md](AGENT_TRUST.md); +> where they differ, the [decision log](#decision-log-2026-07-11) records what +> changed and why. + +## The Bet + +Pracht should let developers define a domain operation once and deliberately project it into the +surfaces where people and agents work: + +- a server-side function used by the web application; +- an HTTP endpoint or progressively enhanced form action; +- a remote Model Context Protocol (MCP) tool; +- a WebMCP tool registered in the page for in-browser agents; +- an optional interactive Preact view rendered inside MCP Apps hosts. + +The result is not an AI SDK and not an automatically generated chatbot. It is a **capability +compiler**: one explicit, typed contract for an application action, with adapters for humans, +programs, and agents. + +```text + ┌─ browser route /
+ ├─ HTTP endpoint +schema + policy + run ───┼─ remote MCP tool (structured result) + ├─ WebMCP tool (in-browser agents) + └─ MCP App (Preact UI) +``` + +This moves Pracht's AI story from “agents can help build this application” to “the finished +application is natively usable by agents.” + +## Why This, Why Now + +MCP has moved beyond local developer tools. Its standard HTTP transport is Streamable HTTP, its +HTTP authorization model is based on OAuth, and production platforms now document both stateless +and stateful remote MCP deployments. Three 2026 developments make the timing concrete rather than +speculative: + +- **MCP Apps became the first official MCP extension (January 2026)**, with host support in + ChatGPT, Claude, Goose, and VS Code — and the official `ext-apps` starter templates include + Preact as a first-class choice. Preact's bundle size is a genuine advantage inside sandboxed + iframe resources. +- **The MCP 2026-07-28 release makes the core protocol stateless.** No session handshake means + remote MCP finally matches how Pracht's Cloudflare and Vercel adapters already want to run: + ordinary stateless request handling at the edge. +- **WebMCP entered Chrome origin trial (Chrome 149, June 2026).** Authored by Google and + Microsoft in the W3C Web ML CG, it lets a web page register typed tools for in-browser agents — + the page itself becomes the tool surface. No framework has integrated it yet. + +Relevant primary references: + +- [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) +- [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP 2026-07-28 release candidate (stateless core, Apps + Tasks extensions)](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) +- [MCP Apps overview](https://modelcontextprotocol.io/extensions/apps/overview) +- [MCP Apps specification (SEP-1865)](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp) +- [WebMCP origin trial announcement](https://developer.chrome.com/blog/ai-webmcp-origin-trial) +- [Cloudflare remote MCP deployment guide](https://developers.cloudflare.com/agents/model-context-protocol/guides/remote-mcp-server/) +- [Web Bot Auth / signed agents](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) + +Pracht already has nearly every compiler input this needs: + +- an explicit application manifest and resolved graph; +- named middleware and adapter-provided request context; +- API dispatch based on Web `Request` and `Response`; +- Preact client, SSR, and islands build environments; +- structured inspection, verification, and MCP tooling; +- Markdown content negotiation for agent-readable pages; +- Node, Cloudflare, and Vercel deployment adapters. + +The missing layer is the application's **domain graph**: which operations exist, what input and +output they accept, who may run them, what side effects they have, and which user interface can +represent their result. + +Pracht is unusually well positioned to add that layer without hiding it in filesystem or compiler +magic. A developer should be able to open one manifest and audit both the page graph and the +capability graph. + +## Candidate Comparison + +Scores are 1–10. “Leverage” asks how much new product surface the feature unlocks; “fit” asks how +well it composes with Pracht's current architecture; “defensibility” asks whether it can become a +reason to choose Pracht rather than a parity checkbox. The total weights leverage at 30%, fit at +25%, defensibility at 20%, time to a convincing proof at 15%, and delivery confidence at 10%. +Higher is better for every column. + +| Candidate | Leverage | Fit | Defensibility | Proof speed | Delivery confidence | Weighted | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Capability graph + agent surfaces | 10 | 9 | 9 | 7 | 6 | **8.70** | +| Streaming SSR + deferred loaders | 8 | 8 | 5 | 5 | 5 | 6.65 | +| More coding-agent diagnostics/autofix | 7 | 9 | 5 | 8 | 8 | 7.35 | +| First-party model/generative-UI SDK | 8 | 6 | 4 | 6 | 5 | 6.10 | +| Client navigation for islands routes | 6 | 8 | 5 | 6 | 6 | 6.30 | + +Streaming SSR remains important and is already tracked in +[#191](https://github.com/JoviDeCroock/pracht/issues/191), but it is framework parity rather than a +new category. More developer MCP tools improve the build loop, but +[Next.js now documents a similar coding-agent MCP surface](https://nextjs.org/docs/app/guides/mcp). +A model SDK would enter a crowded, fast-changing abstraction layer and +would couple Pracht to provider APIs. The capability graph instead builds on stable web primitives +and keeps MCP behind an adapter boundary. + +## Landscape Validation (July 2026) + +A survey of what frameworks and the agent ecosystem actually ship confirms the gap this proposal +targets, and sharpens where the moat is. + +**Every incumbent's agent story is dev-time, not runtime.** Next.js publicly framed its agent +strategy around coding agents — devtools MCP, `agents.md`, skills — after sunsetting its in-browser +agent experiment ([Building Next.js for an agentic future](https://nextjs.org/blog/agentic-future), +February 2026). Astro's acquisition by Cloudflare (January 2026) signals that infrastructure +companies consider frameworks strategic for the agentic web, but no framework's flagship story is +making the *deployed application* usable by end-user agents. + +**"Define once, project everywhere" does not exist yet.** Laravel MCP and Rails ActionMCP define +tools as a second surface, separate from routes, controllers, and forms. Nuxt's `mcp-toolkit` has +the closest framework-native DX but is again a parallel definition. tRPC-MCP bridges existing +procedures but has no forms or UI projection and no policy layer. WebMCP's annotated forms are the +only shipping artifact where one definition doubles as an agent tool — browser-side only. Nobody +ships the full projection this document proposes. + +**The loudest developer pain is trust, not plumbing.** Audits of the remote MCP ecosystem find +roughly 40% of servers require no authentication at all, widespread plaintext credential handling, +and overscoped tokens that defeat per-tool policy +([Scalekit](https://www.scalekit.com/blog/mcp-authentication-authorization-build-vs-buy-roadmap), +[Lenses.io](https://lenses.io/blog/mcp-server-production-security-challenges)). Meanwhile +auto-generated tool sprawl ("GitHub MCP dumps 43 tools into the context window") demonstrably hurts +agent task completion. This validates two of this proposal's core choices: explicit curated +registration and effect classes with policy. Schema-to-tool conversion is a commodity; **the +security model is the product.** + +**Distribution through agent hosts is unproven — treat MCP Apps as a projection, not a growth +channel.** OpenAI's app directory reached ~300 integrations by March 2026 with reportedly little +traffic to partners. Ship the Preact Apps projection because it is cheap once the graph exists, +not as an acquisition strategy. + +**Adjacent signals worth absorbing cheaply, not betting on:** + +- `llms.txt` is broadly published and almost never read (an Ahrefs study of 137k sites found 97% + of the files received zero bot requests). Emitting one from the resolved route/capability graph + is an afternoon of work and a Lighthouse "Agentic Browsing" checkbox — do it, don't market it. +- Web Bot Auth (RFC 9421 signed agents) now covers the large majority of identified AI-browser + traffic and is implemented by major CDNs. Verifying agent identity belongs in Pracht's adapter + request pipeline and folds directly into the principal contract below. +- Agent payments (x402, AP2) are real but crypto-native today; effect classes should leave room + for a future `payment-required` policy rather than integrating now. + +## Product Promise + +> Build the product once. Let people use it on the web and let their agents use the same product +> safely, with the same business rules and an optional Preact interface. + +A project-management application, for example, could expose `projects.search`, `projects.create`, +and `projects.archive`. The browser uses those operations for its normal routes and forms. An agent +can call them as typed tools. When a table, diff, confirmation form, or chart communicates the result +better than text, the same deployment can provide a small Preact view to a host that supports MCP +Apps. Hosts without UI support still receive useful structured and text results. + +The progressive enhancement order is important: + +1. typed server operation; +2. structured tool result; +3. web UI; +4. embedded agent UI. + +No capability should require a model or an MCP Apps-capable host to remain useful. + +## Proposed Developer Model + +### Explicit manifest registration + +Capabilities should be named and registered like shells and middleware: + +```ts +// src/routes.ts +import { defineApp } from "@pracht/core"; + +export const app = defineApp({ + capabilities: { + "projects.search": () => import("./capabilities/projects-search.ts"), + "projects.create": () => import("./capabilities/projects-create.ts"), + "projects.archive": () => import("./capabilities/projects-archive.ts"), + }, + // shells, middleware, routes... +}); +``` + +This is deliberately opt-in. Pracht must not turn every loader or API handler into a public agent +tool. Existing HTTP endpoints often have ambiguous schemas, browser-cookie assumptions, or effects +that are unsafe to expose to autonomous callers. + +### A protocol-neutral capability + +The exact schema API needs a spike. The important contract is shown below; this is illustrative, +not a frozen API: + +```ts +// src/capabilities/projects-search.ts +import { defineCapability, jsonSchema } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Search projects", + description: "Find projects visible to the current account.", + input: jsonSchema({ + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }), + output: jsonSchema({ + type: "object", + properties: { + projects: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + status: { type: "string" }, + }, + required: ["id", "name", "status"], + }, + }, + }, + required: ["projects"], + }), + effect: "read", + middleware: ["auth"], + expose: { + http: { method: "POST", path: "/api/capabilities/projects/search" }, + mcp: true, + webmcp: true, + }, + ui: () => import("../agent-ui/project-results.tsx"), + async run({ input, context, principal, signal }) { + return { + projects: await context.db.projects.search({ + accountId: principal.accountId, + query: input.query, + limit: input.limit, + signal, + }), + }; + }, +}); +``` + +The capability handler should receive an authenticated `principal`, not ask every operation to +re-parse a transport credential. Transport authentication establishes identity; named middleware +and the handler still enforce application authorization. + +### Browser use + +Browser code should be able to invoke the same capability through generated, typed helpers: + +```tsx +import { capability } from "virtual:pracht/capabilities"; + +const searchProjects = capability("projects.search"); + +export function SearchForm() { + return ( + + + + + ); +} +``` + +This example is also exploratory. A less magical `useCapability()` plus `` may fit +Pracht better. The invariant is that browser use, HTTP dispatch, MCP dispatch, and direct server use +all call the same validated handler rather than reimplementing business logic. + +### Agent use + +A build with agent exposure enabled serves Streamable HTTP at a configured endpoint, conventionally +`/mcp`. `tools/list` is generated from the resolved capability graph. `tools/call` performs: + +```text +transport validation + → transport authentication + → capability lookup + → input validation + → named middleware + → capability authorization/policy + → run() + → output validation + → structured result (+ text fallback) +``` + +If `ui` is present and the host negotiated MCP Apps support, the tool metadata references a +build-generated `ui://` resource. Pracht bundles that Preact entry as a sandbox-compatible HTML +resource. The view receives the validated tool input and result through the MCP Apps bridge and can +call only explicitly allowed capabilities. + +### In-browser agent use (WebMCP) + +WebMCP inverts the deployment model: instead of an agent connecting to a remote server, the page +registers typed tools that an in-browser agent (Gemini in Chrome today; origin trial through +Chrome 156) can call while the user watches. For a *web* framework this may be the larger prize — +it is the only emerging standard where the website itself is the tool surface, and no framework +integrates it yet. + +With `expose.webmcp` enabled, the client runtime registers the capability as a page tool on routes +that declare it, reusing the same JSON Schema for parameters. The browser-side tool implementation +is thin: it calls the generated HTTP endpoint, so validation, middleware, policy, and `run()` all +stay server-side. The user's existing session authenticates the call — which is correct for +WebMCP's model (the agent acts *as the signed-in user, in their tab*) and exactly wrong for remote +MCP (see the security model: browser cookies must never authenticate the remote transport). Effect +classes still gate what an in-page agent may do: `destructive` capabilities keep their +server-verified confirmation flow regardless of who clicks. + +The marginal cost on top of the capability graph is small — a client registration shim and typegen — +and it makes Pracht the first framework where one definition serves human forms, remote agents, and +in-browser agents. If WebMCP fails to graduate from its origin trial the shim is deleted without +touching the core contract; that is the point of protocol-neutral capabilities. + +## The Capability Graph + +The Vite plugin should compile manifest registrations and module metadata into a graph that can be +inspected without executing operations: + +```json +{ + "capabilities": [ + { + "name": "projects.search", + "title": "Search projects", + "effect": "read", + "middleware": ["auth"], + "transports": ["http", "mcp"], + "hasUi": true, + "source": "/src/capabilities/projects-search.ts" + } + ] +} +``` + +That graph becomes a source of truth for: + +- `pracht inspect capabilities --json`; +- a new section in `/_pracht`; +- `pracht verify` policy and schema checks; +- `pracht generate capability`; +- route-to-capability dependency and performance analysis; +- generated TypeScript helpers; +- MCP tool/resource registration; +- deployment warnings when an adapter cannot provide a requested feature. + +Unlike a protocol-first MCP module, this graph remains valuable if a different agent protocol wins +later. MCP is the first projection, not the core abstraction. + +## Architecture + +### Package boundary + +The recommended first implementation is an optional `@pracht/capabilities` package rather than +adding the MCP SDK to `@pracht/core`: + +```text +@pracht/core + manifest hooks + shared request context types + +@pracht/capabilities + defineCapability + validation + execution + generated client helpers + +@pracht/vite-plugin + discovery/code generation + separate agent UI build entry + +@pracht/adapter-* + Streamable HTTP handoff, origin/body-limit integration + +@pracht/cli + inspect/verify/generate + local protocol inspector hints +``` + +The MCP SDK and MCP Apps bridge stay optional and out of normal client bundles. Applications that +do not register capabilities pay no runtime or build cost. + +### Schema boundary + +MCP tool schemas use JSON Schema, and the capability graph also needs serializable build metadata. +The MVP should therefore store JSON Schema rather than a runtime-library-specific schema. A helper +can add TypeScript inference, and adapters for libraries that can faithfully emit JSON Schema can +come later. Validation must happen at both input and output boundaries. + +The spike should test JSON Schema 2020-12 support and generated type quality before choosing a +validator. Pracht should not require Zod in application client bundles merely because the current +CLI MCP server uses it. + +### Build environments + +Capability handlers are server-only. Agent UI entries are client-only, separately bundled, and must +not import the handler or server context. This is the same useful separation Pracht already enforces +between route loaders and client-transformed route modules. + +MCP App resources need a stricter output contract than normal routes: + +- one auditable HTML resource per UI entry; +- explicit, deny-by-default content security policy metadata; +- no dependence on the parent page's cookies, DOM, storage, or global CSS; +- only the MCP Apps bridge as the host communication channel; +- small asset budgets reported by `pracht build --analyze`. + +Pracht's islands compiler is conceptually adjacent, but MCP App views should begin as their own +build target. Reusing the island runtime before the security and bridge semantics match would create +accidental coupling. + +### Adapter behavior + +The capability executor should use Web `Request`, `Response`, and `AbortSignal` internally. Node, +Cloudflare, and Vercel can then share protocol parsing and dispatch. Adapter-specific concerns remain +at the edge: + +- maximum request and response sizes; +- streaming and connection lifetime; +- deployment context and background work; +- OAuth storage/coordination; +- rate limiting and observability hooks. + +Stateful sessions must not be a prerequisite for the first release. Read and bounded write tools +should work on ordinary stateless deployments. Durable tasks, elicitation, and resumable workflows +can be later capability projections once their protocol and storage contracts are stable. + +## Security Model + +This feature creates a new attack surface and should ship security before convenience. + +### 1. Explicit exposure + +- A capability is private unless `expose.http` and/or `expose.mcp` is present. +- No API handler or route loader is inferred as a tool. +- The production build prints every externally exposed capability. +- `pracht verify` fails for exposed capabilities without an input schema, output schema, effect + classification, or description. + +### 2. Authentication is not authorization + +- Streamable HTTP should follow MCP's HTTP authorization model when authentication is enabled. +- Transport code resolves credentials into a minimal principal. +- Middleware and capability code authorize that principal against the requested resource. +- Tool descriptions and MCP annotations are hints to clients, never enforcement controls. +- Browser session cookies must not silently authenticate the remote agent transport. (WebMCP page + tools are the deliberate exception: they run in the user's tab as the signed-in user, through the + same HTTP endpoint and policy chain as a human form submission.) +- Adapters should offer Web Bot Auth (RFC 9421 HTTP Message Signatures) verification as a request + pipeline hook, so the principal can record *which* agent acted and on whose behalf. Signed-agent + identity is now implemented across major CDNs and covers most identified AI-browser traffic; + no framework surfaces it to application code yet. + +### 3. Effect classes and approvals + +Every capability declares one of: + +| Effect | Meaning | Default exposure policy | +| --- | --- | --- | +| `read` | No externally visible mutation | May be exposed after auth/policy checks | +| `write` | Creates or changes recoverable state | Requires idempotency strategy | +| `destructive` | Deletes, publishes, pays, sends, or changes access | Requires server-verifiable confirmation | + +Host-reported user approval is useful UX but not a sufficient authorization boundary. A destructive +operation should use a prepare/commit flow or a short-lived confirmation token bound to the +principal, normalized arguments, action, and expiry. The server validates that token at commit +time. The first public release can support only `read` and carefully bounded `write` effects rather +than pretending destructive approval is solved. + +### 4. Transport hardening + +At minimum: + +- validate `Origin` according to the Streamable HTTP requirements; +- reuse adapter body-size limits and add result-size limits; +- require exact content types and protocol versions; +- apply per-principal and per-capability rate limits through middleware hooks; +- redact validation errors and internal diagnostics in production; +- emit audit events with principal, capability, effect, outcome, duration, and trace identifier; +- treat tool output as untrusted data when it includes third-party content. + +### 5. UI isolation + +MCP App UI resources must use the extension's sandbox and CSP model. Pracht should statically reject +obvious server-only imports in agent UI entries, expose requested permissions in build output, and +make allowed UI-to-server capability calls auditable. + +## What Not to Build + +- Do not make the framework choose a model or model provider. +- Do not create tools automatically from every API route. +- Do not turn arbitrary model-generated JSX into executable application code. +- Do not promise that client approval annotations secure destructive operations. +- Do not require developers to replace their existing browser UX with chat. +- Do not put MCP packages in the default client runtime. +- Do not couple the capability definition to MCP-specific result types. +- Do not begin with long-running, stateful agent workflows; establish the stateless contract first. + +## Delivery Plan + +### Stage 0: Architecture spike + +Build one vertical demo outside the stable API: + +- `projects.search` (`read`) and `projects.create` (`write`); +- JSON Schema input/output validation; +- direct server invocation and browser form invocation; +- generated `tools/list` and `tools/call` over local Streamable HTTP; +- one small Preact MCP App result view; +- Node and Cloudflare builds; +- an intentionally unauthorized cross-account call that the test proves is rejected. + +The spike succeeds only if the same handler serves every invocation path, server-only imports stay +out of both browser bundles, and an MCP host without Apps support receives a useful fallback. + +### Stage 1: Capability core + +- `defineCapability()` and manifest registration; +- capability resolver/registry and type generation; +- schema validation and stable error mapping; +- direct invocation plus generated HTTP endpoint; +- `inspect`, `verify`, `generate`, and devtools graph support; +- unit, adapter, and E2E tests for auth separation and bundle isolation. + +This stage is useful even without MCP: it gives Pracht typed server actions that are not bound to a +particular transport. + +### Stage 2: Remote MCP projection + +- stateless Streamable HTTP endpoint; +- tool listing/calling generated from the graph; +- authentication integration hooks and principal contract; +- structured output plus deterministic text fallback; +- effect annotations, idempotency hooks, limits, audit events, and adapter conformance tests; +- a first `pracht eval` harness: a scripted MCP client that attempts a described task + (e.g. "find the Atlas project and archive it") against the capability graph and reports + completion, tool-call transcript, and policy denials. No framework offers app developers a way + to test "can an agent actually complete this task through my tools?" — this operationalizes the + proof metrics below as repeatable CI checks, in the same spirit as Pracht's Playwright E2E story. + +Read-only capabilities should be the first supported production profile. + +### Stage 2b: WebMCP projection + +- client-side tool registration shim behind `expose.webmcp`, active only on routes that opt in; +- shared JSON Schema reuse for tool parameters; calls dispatch through the generated HTTP endpoint + so all enforcement stays server-side; +- devtools and `inspect` surface which routes register which page tools; +- feature-detection and graceful no-op outside the origin trial; +- explicitly disposable: if WebMCP does not graduate, the shim is removed without core changes. + +### Stage 3: Preact MCP Apps projection + +- separate agent UI Vite environment; +- MCP App tool/resource metadata and bridge helper; +- CSP/permission declaration and verification; +- Preact component starter and a host test harness; +- per-view asset budgets in `build --analyze`; +- graceful fallback testing for hosts without the extension. + +### Stage 4: Advanced workflows + +Only after the earlier contracts are proven: + +- server-verifiable prepare/commit confirmations; +- resumable long-running tasks; +- progress and cancellation; +- capability composition; +- agent-facing resources and prompts; +- deployment-specific durable state helpers. + +## Proof Metrics + +The feature should earn its complexity. A six-week experimental cycle should answer these +questions with measured results: + +| Question | Target signal | +| --- | --- | +| Does one contract actually remove duplication? | Demo has one business handler for web, HTTP, MCP, and embedded UI paths | +| Is the output understandable to agents? | Three current MCP clients complete search/create tasks without custom prompts | +| Is it still Pracht-small? | Zero added client bytes without a capability UI; initial UI view has an enforced budget | +| Can teams audit it? | `inspect` shows source, schemas, policy, effect, and every exposure | +| Is it portable? | Same demo passes Node and Cloudflare adapter conformance tests | +| Is the auth boundary real? | Cross-tenant, missing-scope, replay, oversized-body, and invalid-origin tests fail closed | +| Is fallback useful? | A host without MCP Apps can complete the same task from structured/text results | + +Adoption metrics after an experimental release should focus on activated applications (a deployed, +successfully called capability), capabilities per activated app, repeat calls, schema/authorization +failure rates, and the percentage of applications using the same capability from both browser and +agent surfaces. Package downloads alone would not validate the product thesis. + +## Risks and Countermeasures + +| Risk | Countermeasure | +| --- | --- | +| MCP changes faster than Pracht | Keep `defineCapability` protocol-neutral and isolate MCP in a projection package | +| WebMCP dies in origin trial | Ship it as a disposable client shim over the HTTP projection; zero coupling to the core contract | +| Agent hosts never send meaningful traffic | Value must stand on web + HTTP + trust layer alone; MCP surfaces are projections, not the payoff | +| “One definition” becomes an over-generalized RPC framework | Start with explicit server operations and two transports; avoid distributed-workflow primitives | +| Developers expose unsafe mutations | Private-by-default registration, effect classes, verification failures, read-only first profile | +| OAuth implementation overwhelms the framework | Define authentication hooks first; provide deployment recipes before owning an authorization server | +| Embedded UI duplicates existing pages | Optimize for small task views; allow shared presentation components without promising full route reuse | +| Schema ergonomics are worse than API routes | Prove inference and validation in Stage 0 before stabilizing the API | +| Optional dependencies inflate normal apps | Separate package and build target, tree-shaken when no capabilities are registered | +| Agent calls are hard to debug | Capability graph, structured traces, devtools history, deterministic invocation replay fixtures | + +## Decisions to Make in the Spike + +1. Does JSON Schema-first provide acceptable TypeScript inference, or should Pracht accept a + standard schema adapter that can guarantee JSON Schema emission? +2. Should browser invocation use generated HTTP endpoints, a single RPC endpoint, or both? +3. Is capability registration best in `defineApp()`, a separate `src/capabilities.ts` manifest, or + an imported `defineCapabilities()` value referenced by the app manifest? +4. What is the smallest principal contract that works across Node, Cloudflare, and Vercel without + prescribing an authentication library? +5. Which MCP SDK version can be isolated cleanly enough that protocol updates do not force core + framework releases? +6. Can an MCP App Preact entry share leaf components and styles with a route without inheriting + unsafe globals or bloating the single-resource output? +7. Which policy belongs in framework verification, and which belongs in deploy-specific middleware? +8. Can the WebMCP registration shim share the generated HTTP client helpers, and how does it + feature-detect hosts during the origin trial without shipping dead code to every route? +9. Where does Web Bot Auth verification live in each adapter's request pipeline, and what does the + verified agent identity look like on the principal? + +## Decision Log (2026-07-11) + +Stage 1 (capability core), Stage 2b (WebMCP), and the trust layer shipped — +plus `pracht eval` and the prepare/commit confirmation flow, both pulled +forward from later stages. This log records how each spike question was +answered, where the implementation deviated from the plan above, and what is +still open. The shipped contracts live in [CAPABILITIES.md](CAPABILITIES.md) +and [AGENT_TRUST.md](AGENT_TRUST.md). + +### Answers to the spike questions + +1. **JSON Schema-first, no schema library.** Schemas are plain JSON Schema + objects checked by a dependency-free subset validator; keywords outside the + subset make `defineCapability()` throw and `pracht verify` fail, so a + silently ignored keyword can never widen an exposed contract. The proposal's + `jsonSchema()` helper was dropped. TypeScript inference is generated rather + than inferred: `pracht typegen` emits capability input/output types from the + resolved graph onto `Register["capabilities"]`, and `invokeCapability()`, + the browser's `callCapability()`, and the capability test host infer both + sides from the capability name. The untyped `invokeCapability(...)` + form still works for unregistered names — which also means a mismatched + input on a registered name falls back to the untyped overload instead of + erroring at the call site (runtime validation still rejects it). +2. **Generated per-capability HTTP endpoints.** `POST + /api/capabilities/` (or a custom `expose.http.path`) dispatched in the + framework request handler, plus `callCapability()` from + `virtual:pracht/capabilities`. No single RPC endpoint, and no generated + `` component — the less magical option the proposal itself + anticipated. +3. **Registration lives in `defineApp({ capabilities })`** — the same manifest + seam as shells and middleware, serializable data only. +4. **The principal contract narrowed to verified agent identity.** `run()` + does not receive a `principal` argument; Web Bot Auth verification puts + `context.agent = { verified, agentDomain, keyId } | null` on the request + context, and application authorization stays in named middleware. + Confirmation tokens bind to the verified `keyid` or `"anonymous"` — + [AGENT_TRUST.md](AGENT_TRUST.md#honest-limitations) records what anonymous + binding does and does not give you. +5. **MCP SDK isolation: deferred with the remote MCP projection.** Stage 2 is + unbuilt; `expose.mcp` is accepted and recorded in the graph but nothing + serves it, so no SDK dependency decision was needed yet. +6. **MCP Apps component sharing: deferred with Stage 3.** `hasUi` is always + `false` in the graph. +7. **Verification owns the static contract; middleware owns deployment + policy.** `pracht verify` fails on incomplete exposed contracts, destructive + agent-projection exposure, unsupported schema keywords, and statically + unanalyzable WebMCP schemas. The runtime enforces effect gates and + `agentPolicy` per dispatch. Rate limits, quotas, and result-size budgets + stay in middleware and adapters ([AGENT_TRUST.md — operational + hardening](AGENT_TRUST.md#operational-hardening-what-the-framework-does-not-do-yet)). +8. **The WebMCP shim shares `callCapability` and ships zero dead code.** It + lives in its own chunk (`virtual:pracht/webmcp`) behind feature detection: + browsers without the API never download it, pages without webmcp-exposed + capabilities never reference it, and it targets `document.modelContext` + (Chrome 150+) with the deprecated `navigator.modelContext` as fallback. +9. **Web Bot Auth verification runs once per request in + `handlePrachtRequest()`**, shared by every adapter because it uses only Web + platform APIs (`Headers`, `fetch`, `crypto.subtle`). The verified identity + is `context.agent`; directory fetching is allowlist-only (SSRF-safe), + fail-closed, with the replay caveat documented. + +### Deviations from the delivery plan + +- **Destructive capabilities shipped in v1, not Stage 4.** The plan said the + first release should stop at `read`/bounded `write`. Instead, `destructive` + may expose over HTTP now because the server-verified prepare/commit flow + ships with it: without `PRACHT_CONFIRMATION_SECRET` every destructive call + fails closed, and `webmcp`/`mcp` exposure stays rejected. The stateless + HMAC's replay window and anonymous-principal limits are documented rather + than papered over. +- **Stage 2b (WebMCP) landed before Stage 2 (remote MCP).** The in-browser + story plus the trust layer is the differentiator no other framework ships; + remote MCP remains the next projection and its graph seam (`expose.mcp`) + already exists. +- **`pracht eval` arrived early, speaking HTTP.** The Stage 2 plan attached it + to a scripted MCP client; the shipped harness runs scenarios against the + capability HTTP projection (including the confirmation flow) and will grow + an MCP transport with the remote projection. + +### Still open + +- Remote MCP projection (`/mcp` Streamable HTTP) and MCP Apps views — + Stages 2 and 3, unchanged. +- Framework-level rate limiting, write-idempotency helpers, and result-size + limits — currently documented middleware/app responsibilities, not + primitives. +- Registered-name input mismatches fall back to the untyped overload (see + answer 1) instead of failing the build. + +### Consolidation pass (2026-07-11) + +A same-day cohesion review tied the product and API surface together; the +changes are behavioral, so they are recorded here rather than left to git +history. + +- **The wire protocol got one home.** `@pracht/capabilities` now owns the + HTTP path formula, the confirmation and transport header names, the + `CapabilityErrorCode` union, the envelope types, the schema→TypeScript + printer used by typegen, and the shared static extractor + (`@pracht/capabilities/static`). The framework, the Vite plugin's generated + modules, and the CLI import from it — previously `capabilityHttpPath()` + existed three times and the extractor twice (the two copies had already + drifted on shorthand-property handling). `@pracht/core` now depends on + `@pracht/capabilities`; the original zero-dependency stance bought + duplication in four packages and was reversed deliberately. +- **`` partially revisits answer 2.** The proposal's + generated `` stays dead, but the framework's existing + `` accepts a `capability` prop: it posts to the capability's HTTP + projection (the exact endpoint agents call), the server coerces + form-encoded fields onto the input schema, and a successful no-JS document + post 303s back to the same-origin referer. One contract now genuinely + serves humans and agents on the write path, not just the read path. +- **Effect classes drive the client cache.** Browser-side capability calls + (generated `callCapability()` and ``) announce settlement + on a window event; the route runtime revalidates loader data after + successful non-`read` calls. The effect class stopped being agent-only + ceremony. Opt out per call with `revalidate: false`. +- **`context.agent` is typed everywhere.** `CapabilityContext` (the default + `defineCapability` context) and the framework's `PrachtRequestContext` both + carry `agent?: PrachtAgentIdentity | null`; the runtime no longer smuggles + it through a type assertion, and capability authors need no hand-rolled + context interface. +- **Audit events distinguish WebMCP.** The shim marks its dispatches with the + transport header; `CapabilityAuditEvent.transport` gained `"webmcp"` + (client-declared, informational). +- **Declared-but-unserved `expose.mcp` is labeled.** `pracht verify` warns + and the dev banner prints `mcp(unserved)` until Stage 2 ships. +- **Eval scenarios gained `confirm`.** Sugar for the confirmation header; + raw `headers` still work. +- **`llmsTxt` deliberately stays a plugin option.** The cohesion review + considered moving it into `defineApp({ agents })`, but emission must work + in pages mode, which has no manifest. The split is: request-time trust + config lives in the manifest (`agents`), build-time emission lives in the + plugin (`llmsTxt`, `capabilitiesDir`). + +## Final Recommendation + +Proceed with Stage 0 as the next product exploration. Continue streaming SSR and other framework +parity work in parallel, but frame the capability graph as the next category-defining bet. + +Position it as pain relief, not plumbing. “Define once, project everywhere” is the architecture; +the product is **the safest, fastest way to make a production web application usable by agents** — +because the landscape's loudest failures are unauthenticated servers, overscoped tokens, and +auto-generated tool sprawl, and the capability graph's explicit registration, effect classes, +principal contract, agent identity, and eval harness answer exactly those. Schema-to-tool +conversion is a commodity; the trust layer is the moat. + +Pracht's durable advantage is not that it can bolt an MCP endpoint onto a Vite server. Many tools +can do that. The advantage is that it can compile an explicit, inspectable application graph into a +fast web product and a safe agent product — for remote agents over stateless Streamable HTTP, for +in-browser agents over WebMCP, and for embedded views with Preact, where its size is a real edge +inside sandboxed resources. No framework serves all three from one definition today. That is a +coherent extension of “full-stack Preact, per route”: **full-stack Preact, per audience**. diff --git a/docs/LLMS_TXT.md b/docs/LLMS_TXT.md new file mode 100644 index 00000000..fe0b35d3 --- /dev/null +++ b/docs/LLMS_TXT.md @@ -0,0 +1,109 @@ +# llms.txt + +Pracht can emit an [llms.txt](https://llmstxt.org) file — a markdown index of +your site's pages, API endpoints, and [capabilities](CAPABILITIES.md) that LLM +agents (and audits such as Lighthouse's Agentic Browsing check) read to +discover what a site offers. The file is generated from the resolved app +graph, so it always matches the routes the app actually serves. The feature is +opt-in and has zero cost when disabled. + +## Enabling + +```ts +// vite.config.ts +pracht({ + adapter: nodeAdapter(), + llmsTxt: { + title: "My App", // defaults to package.json "name" + description: "What the app does.", // defaults to package.json "description" + origin: "https://example.com", // emit absolute URLs; relative when omitted + include: ["pages", "api", "capabilities"], // sections to emit (default: all) + }, +}) +``` + +`llmsTxt: {}` is enough — the title falls back to the app's package.json +`name` and the description to its `description` (the blockquote is omitted +when neither is set). + +## What it does + +- **Build** — `pracht build` writes `dist/client/llms.txt`. All three adapters + serve it as a regular static file: the Node handler and the Vercel Build + Output `handle: filesystem` route pick it up directly, and the Cloudflare + worker serves it through the `ASSETS` binding. +- **Dev** — the dev server serves `/llms.txt` live from the current app graph + (routes added or removed show up on the next request). With the Cloudflare + adapter the Cloudflare vite plugin owns the dev server, so `/llms.txt` is + only available in build output there. + +## Output format + +Per the [llms.txt spec](https://llmstxt.org): an H1 title, an optional +blockquote summary, and H2 sections containing markdown link lists. + +``` +# My App + +> What the app does. + +## Pages + +- [/](/): supports `Accept: text/markdown` +- [/blog/hello-world](/blog/hello-world) +- [/pricing](/pricing) + +## API + +- [/api/echo](/api/echo): POST +- [/api/health](/api/health): GET + +## Capabilities + +- [notes.create](/api/capabilities/notes/create): POST (write) — Add a new note. +- [notes.purge](/api/capabilities/notes/purge): POST (destructive, requires confirmation) — Delete matching notes. +- [notes.search](/api/capabilities/notes/search): POST (read) — Find notes matching a query. +``` + +Output is deterministic: entries are sorted by path with a locale-independent +comparison, so repeated builds produce byte-identical files. + +### Pages + +- Static routes are always listed, whatever their render mode — they are real + URLs an agent can fetch. +- Dynamic routes (`/blog/:slug`) are listed only when they are SSG/ISG routes + with a `getStaticPaths()` export; each prerendered instance becomes its own + entry. Dynamic SSR/SPA routes are skipped — there is no concrete URL to + link. +- Routes with a server-only `markdown` export (Markdown-for-Agents content + negotiation, see [docs/DATA_LOADING.md](DATA_LOADING.md)) are annotated with + `supports \`Accept: text/markdown\``. +- Link names are the route paths themselves. Page titles are not derivable + statically (`head()` needs a request), and paths are unambiguous for agents. + +### API + +API routes are listed as endpoint patterns (including dynamic params such as +`/api/users/:id`) with their detected HTTP methods as the note. Handlers +exported only as `default` produce no method note. + +### Capabilities + +Every HTTP-exposed [capability](CAPABILITIES.md) is listed by name, linking to +its dispatch endpoint, with its effect class and description as the note. +Destructive capabilities are annotated with `requires confirmation` — their +first dispatch answers `409 confirmation_required` per the +[agent trust layer](AGENT_TRUST.md). Private capabilities (no `expose`) are +omitted: there is no URL an agent could call. Entries are sorted by capability +name. + +## Notes + +- `/llms.txt` is reserved while the option is enabled; an app route at that + path is shadowed in dev (a warning is logged) and by the static file in + production. +- If you need curated sections or an `llms-full.txt` with inlined page + content, keep using a custom plugin — see + [examples/docs/vite-plugin-llms-txt.ts](../examples/docs/vite-plugin-llms-txt.ts) + for a frontmatter-driven variant. diff --git a/docs/MCP.md b/docs/MCP.md index 65108198..2cef0235 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -61,6 +61,7 @@ the server from the project directory. | --------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `inspect_routes` | `cwd?` | Resolved page routes: path, id, render mode, hydration mode, prefetch strategy, speculation rules, shell, middleware, loader file, plus `notFound` (the app-level 404 page, or `null`). Unset options serialize as `null`. Same as `pracht inspect routes --json`. | | `inspect_api` | `cwd?` | Resolved API routes: endpoint path, source file, exported HTTP methods, `hasDefaultHandler` (default catch-all export). Same as `pracht inspect api --json`. | +| `inspect_capabilities` | `cwd?` | Registered capabilities: name, effect class, exposure transports, HTTP path, middleware, source file, input/output JSON Schemas. Same as `pracht inspect capabilities --json`. | | `inspect_build` | `cwd?` | Build metadata: adapter target, client entry URL, CSS/JS manifests (requires a prior `pracht build`). Same as `pracht inspect build --json`. | | `doctor` | `cwd?` | Wiring diagnostics with per-check status. Same as `pracht doctor --json`. | | `verify` | `cwd?`, `changed?` (boolean, maps to `--changed`) | Framework verification checks with scope info — including `defineApp({ constraints })` enforcement and app-graph snapshot freshness. Same as `pracht verify --json`. | diff --git a/e2e/capabilities.test.ts b/e2e/capabilities.test.ts new file mode 100644 index 00000000..586bbdaf --- /dev/null +++ b/e2e/capabilities.test.ts @@ -0,0 +1,449 @@ +import { execFile } from "node:child_process"; +import { createPrivateKey, sign as nodeSign } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect, test } from "@playwright/test"; + +const execFileAsync = promisify(execFile); + +// Runs against examples/basic (port 3103), which registers five capabilities: +// notes.search — read, expose.http + expose.webmcp +// notes.create — write, expose.http +// notes.purge — destructive, expose.http (prepare/commit confirmation flow) +// agent.whoami — read, expose.http (echoes the Web Bot Auth identity) +// agent.ping — read, expose.http, agentPolicy: "require" +// The dev server runs with PRACHT_CONFIRMATION_SECRET set (playwright.config.ts). + +// --------------------------------------------------------------------------- +// HTTP projection +// --------------------------------------------------------------------------- + +test("http-exposed capability answers with the ok envelope", async ({ request }) => { + const response = await request.post("/api/capabilities/notes/search", { + data: { query: "capabilities" }, + }); + + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("application/json"); + + const body = await response.json(); + expect(body.ok).toBe(true); + expect(Array.isArray(body.data.notes)).toBe(true); + expect(body.data.notes.length).toBeGreaterThan(0); + expect(body.data.notes[0]).toMatchObject({ title: "Capabilities" }); +}); + +test("invalid input returns 400 with path-scoped issues", async ({ request }) => { + const response = await request.post("/api/capabilities/notes/search", { + data: { query: "", limit: 99 }, + }); + + expect(response.status()).toBe(400); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("invalid_input"); + expect(body.error.issues).toEqual([ + { path: "/query", message: "must be at least 1 character(s) long" }, + { path: "/limit", message: "must be <= 20" }, + ]); +}); + +test("unknown capability paths return the typed 404 envelope", async ({ request }) => { + const response = await request.post("/api/capabilities/notes/missing", { data: {} }); + + expect(response.status()).toBe(404); + const body = await response.json(); + expect(body.error.code).toBe("unknown_capability"); +}); + +test("capability endpoints reject non-POST methods", async ({ request }) => { + const response = await request.get("/api/capabilities/notes/search"); + + expect(response.status()).toBe(405); + const body = await response.json(); + expect(body.error.code).toBe("method_not_allowed"); +}); + +// --------------------------------------------------------------------------- +// Direct server invocation (loader) + browser invocation (callCapability) +// --------------------------------------------------------------------------- + +test("loader invokes notes.search server-side and SSRs the results", async ({ request }) => { + const response = await request.get("/notes"); + expect(response.status()).toBe(200); + + const html = await response.text(); + // Seeded notes matching the loader's query render server-side. + expect(html).toContain("Manifest routing"); + expect(html).toContain('data-testid="notes-list"'); +}); + +test(" creates a note through the capability endpoint and auto-revalidates", async ({ + page, +}) => { + await page.goto("/notes"); + await expect(page.locator('[data-testid="notes-list"] li').first()).toBeVisible(); + // Wait for hydration so the submit handler is attached before clicking. + await expect(page.locator('[data-testid="create-note-form"]')).toHaveAttribute( + "data-hydrated", + "true", + ); + + await page.fill('[data-testid="create-note-form"] input[name="title"]', "A browser note"); + await page.click('[data-testid="create-note-form"] button'); + + await expect(page.locator('[data-testid="create-note-status"]')).toContainText( + 'Created "A browser note"', + ); + // Effect-driven revalidation re-runs the loader without any manual + // revalidate() call; the new note matches the "note" query. + await expect(page.locator('[data-testid="notes-list"]')).toContainText("A browser note"); +}); + +test(" follows endpoint redirects in the browser", async ({ page }) => { + const endpointMethods: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/dashboard") { + endpointMethods.push(request.method()); + } + }); + + await page.goto("/notes"); + await expect(page.locator('[data-testid="create-note-form"]')).toHaveAttribute( + "data-hydrated", + "true", + ); + await page + .locator('[data-testid="create-note-form"] button') + .evaluate((button) => button.setAttribute("formaction", "/api/dashboard?redirect=1")); + await page.fill('[data-testid="create-note-form"] input[name="title"]', "Redirect me"); + await page.click('[data-testid="create-note-form"] button'); + + await expect(page).toHaveURL("/"); + expect(endpointMethods).toEqual(["POST"]); +}); + +test("no-JS form posts hit the same capability contract and redirect back", async ({ request }) => { + // The form-encoded fallback of : fields are coerced onto + // the input schema and a successful document post 303s back to the page. + const response = await request.post("/api/capabilities/notes/create", { + form: { title: "A no-js note", body: "Posted without JavaScript." }, + headers: { accept: "text/html", referer: "http://localhost:3103/notes" }, + maxRedirects: 0, + }); + expect(response.status()).toBe(303); + expect(response.headers().location).toContain("/notes"); + + // Without a document accept header the JSON envelope answers as usual. + const jsonResponse = await request.post("/api/capabilities/notes/create", { + form: { title: "A form-encoded note", body: "Posted as urlencoded." }, + }); + expect(jsonResponse.status()).toBe(200); + const body = await jsonResponse.json(); + expect(body.ok).toBe(true); + expect(body.data.note.title).toBe("A form-encoded note"); +}); + +// --------------------------------------------------------------------------- +// WebMCP projection +// --------------------------------------------------------------------------- + +interface FakeRegisteredTool { + name: string; + description: string; + inputSchema: Record; +} + +test("webmcp shim registers page tools and execute() round-trips over HTTP", async ({ page }) => { + // Fake the Chrome origin-trial API (document.modelContext.registerTool) + // before any page script runs, so the client entry's feature detection + // loads the shim and registers tools against it. + await page.addInitScript(() => { + const registered: unknown[] = []; + (window as unknown as { __webmcpTools: unknown[] }).__webmcpTools = registered; + (document as unknown as { modelContext: unknown }).modelContext = { + registerTool(tool: unknown) { + registered.push(tool); + return Promise.resolve(); + }, + }; + }); + + await page.goto("/notes"); + await page.waitForFunction( + () => (window as unknown as { __webmcpTools?: unknown[] }).__webmcpTools?.length, + ); + + const tools = await page.evaluate(() => + (window as unknown as { __webmcpTools: FakeRegisteredTool[] }).__webmcpTools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ); + + // Only webmcp-exposed capabilities become page tools, with their real schema. + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("notes.search"); + expect(tools[0].description).toBe("Find notes whose title or body matches the query."); + expect(tools[0].inputSchema).toMatchObject({ + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + }); + + // execute() dispatches through the HTTP projection with the page's session. + const result = await page.evaluate(async () => { + const tool = ( + window as unknown as { + __webmcpTools: { name: string; execute: (input: unknown) => Promise }[]; + } + ).__webmcpTools.find((candidate) => candidate.name === "notes.search"); + return tool!.execute({ query: "capabilities" }); + }); + + const content = (result as { content: { type: string; text: string }[] }).content; + expect(content[0].type).toBe("text"); + const envelope = JSON.parse(content[0].text); + expect(envelope.ok).toBe(true); + expect(envelope.data.notes[0].title).toBe("Capabilities"); +}); + +test("without the WebMCP API the page works and registers nothing", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + + await page.goto("/notes"); + await expect(page.locator('[data-testid="notes-list"] li').first()).toBeVisible(); + + const hasTools = await page.evaluate( + () => (window as unknown as { __webmcpTools?: unknown[] }).__webmcpTools !== undefined, + ); + expect(hasTools).toBe(false); + expect(errors).toEqual([]); +}); + +// --------------------------------------------------------------------------- +// Web Bot Auth (verified agent identity) +// --------------------------------------------------------------------------- + +// The example app's manifest trusts this test agent's *public* key; the +// private part below signs requests in-test only. +const TEST_AGENT_JWK = { + kty: "OKP", + crv: "Ed25519", + d: "JZlLQqnxH-0O_1mfnuqDBB1U5XgqETE5eiRXxXRhZNM", + x: "s5n91rPm5ymJjl--scT4WWq7HE9kUdj-6sVe5r__xgc", +}; +const TEST_AGENT_KEY_ID = "9zaO23t4-sitQq-zx7KAn4Q1Ds_W1PF07ozJfoP3H70"; + +/** + * Sign per draft-meunier-web-bot-auth-architecture-02: covered components + * `@authority` + `signature-agent`, Ed25519, tag "web-bot-auth". + */ +function webBotAuthHeaders(authority: string): Record { + const now = Math.floor(Date.now() / 1000); + const signatureAgent = '"https://test-agent.example"'; + const params = + `("@authority" "signature-agent");created=${now};expires=${now + 300}` + + `;keyid="${TEST_AGENT_KEY_ID}";alg="ed25519";tag="web-bot-auth"`; + const base = [ + `"@authority": ${authority}`, + `"signature-agent": ${signatureAgent}`, + `"@signature-params": ${params}`, + ].join("\n"); + + const key = createPrivateKey({ key: TEST_AGENT_JWK, format: "jwk" }); + const signature = nodeSign(null, Buffer.from(base, "utf-8"), key); + + return { + "signature-agent": signatureAgent, + "signature-input": `sig1=${params}`, + signature: `sig1=:${signature.toString("base64")}:`, + }; +} + +test("unsigned requests in observe mode are served with a null agent", async ({ request }) => { + const response = await request.post("/api/capabilities/agent/whoami", { data: {} }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body).toEqual({ ok: true, data: { verified: false } }); +}); + +test("signed requests surface the verified agent identity", async ({ request }) => { + const response = await request.post("/api/capabilities/agent/whoami", { + data: {}, + headers: webBotAuthHeaders("localhost:3103"), + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(body.data).toEqual({ + verified: true, + agentDomain: "test-agent.example", + keyId: TEST_AGENT_KEY_ID, + }); +}); + +test('agentPolicy "require" rejects unsigned requests with the 401 envelope', async ({ + request, +}) => { + const response = await request.post("/api/capabilities/agent/ping", { data: {} }); + expect(response.status()).toBe(401); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("agent_required"); +}); + +test('agentPolicy "require" serves verified agents', async ({ request }) => { + const response = await request.post("/api/capabilities/agent/ping", { + data: {}, + headers: webBotAuthHeaders("localhost:3103"), + }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ ok: true, data: { pong: true } }); +}); + +test("a bad signature does not verify", async ({ request }) => { + const headers = webBotAuthHeaders("localhost:3103"); + // Flip the first base64 character of the signature bytes ("sig1=:" is 6 chars). + const flipped = headers.signature[6] === "A" ? "B" : "A"; + headers.signature = headers.signature.slice(0, 6) + flipped + headers.signature.slice(7); + const response = await request.post("/api/capabilities/agent/whoami", { + data: {}, + headers, + }); + const body = await response.json(); + expect(body.data).toEqual({ verified: false }); +}); + +// --------------------------------------------------------------------------- +// Destructive capability confirmation flow (prepare/commit) +// --------------------------------------------------------------------------- + +test("destructive capability requires confirmation, then commits with the token", async ({ + request, +}) => { + // Seed a note the purge will target. + const created = await request.post("/api/capabilities/notes/create", { + data: { title: "E2E purge target", body: "to be deleted" }, + }); + expect((await created.json()).ok).toBe(true); + + // Prepare: no token → 409 with a confirmation token, nothing deleted. + const prepare = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "E2E purge target" }, + }); + expect(prepare.status()).toBe(409); + const prepareBody = await prepare.json(); + expect(prepareBody.error.code).toBe("confirmation_required"); + const token = prepareBody.error.confirmationToken as string; + expect(typeof token).toBe("string"); + + // The note still exists — prepare must not run the capability. + const searchAfterPrepare = await request.post("/api/capabilities/notes/search", { + data: { query: "E2E purge target" }, + }); + expect((await searchAfterPrepare.json()).data.notes.length).toBeGreaterThan(0); + + // Tampered token → 403, fail closed. + const tampered = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "E2E purge target" }, + headers: { "x-pracht-confirm": `${token}x` }, + }); + expect(tampered.status()).toBe(403); + expect((await tampered.json()).error.code).toBe("confirmation_invalid"); + + // Different input with a valid token → 403 (token is input-bound). + const mismatched = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "Manifest" }, + headers: { "x-pracht-confirm": token }, + }); + expect(mismatched.status()).toBe(403); + + // Commit: same input + token → runs. + const commit = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "E2E purge target" }, + headers: { "x-pracht-confirm": token }, + }); + expect(commit.status()).toBe(200); + const commitBody = await commit.json(); + expect(commitBody.ok).toBe(true); + expect(commitBody.data.purged).toBeGreaterThan(0); + + const searchAfterCommit = await request.post("/api/capabilities/notes/search", { + data: { query: "E2E purge target" }, + }); + expect((await searchAfterCommit.json()).data.notes).toEqual([]); +}); + +// --------------------------------------------------------------------------- +// pracht eval CLI +// --------------------------------------------------------------------------- + +const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const cliEntry = resolve(repoRoot, "packages/cli/bin/pracht.js"); + +test("pracht eval runs the example scenario against the dev server", async () => { + const { stdout } = await execFileAsync( + process.execPath, + [cliEntry, "eval", "--url", "http://localhost:3103"], + { cwd: resolve(repoRoot, "examples/basic") }, + ); + expect(stdout).toContain("PASS notes agent flow"); + expect(stdout).toContain("confirmation_required"); + expect(stdout).toContain("1 scenario(s) passed, 0 failed"); +}); + +test("pracht eval --start launches the app, runs the scenario, and stops it", async () => { + const scenario = resolve(repoRoot, "e2e/fixtures/start-flow.eval.json"); + const serverScript = resolve(repoRoot, "e2e/fixtures/mini-capability-server.mjs"); + + const { stdout } = await execFileAsync( + process.execPath, + [ + cliEntry, + "eval", + scenario, + "--start", + `"${process.execPath}" "${serverScript}" 3177`, + "--url", + "http://localhost:3177", + ], + { cwd: resolve(repoRoot, "examples/basic") }, + ); + expect(stdout).toContain("Waiting for http://localhost:3177"); + expect(stdout).toContain("PASS start flow"); + expect(stdout).toContain("1 scenario(s) passed, 0 failed"); + + // The started server must be gone once eval exits. + await new Promise((resolveDelay) => setTimeout(resolveDelay, 300)); + await expect( + fetch("http://localhost:3177", { signal: AbortSignal.timeout(1_000) }), + ).rejects.toThrow(); +}); + +test("pracht eval exits 1 on a failing scenario", async () => { + const failingScenario = resolve(repoRoot, "e2e/fixtures/failing.eval.json"); + const result = await execFileAsync( + process.execPath, + [cliEntry, "eval", failingScenario, "--url", "http://localhost:3103", "--json"], + { cwd: resolve(repoRoot, "examples/basic") }, + ).then( + (value) => ({ code: 0, stdout: value.stdout }), + (error: { code?: number; stdout?: string }) => ({ + code: error.code ?? 1, + stdout: error.stdout, + }), + ); + + expect(result.code).toBe(1); + const report = JSON.parse(result.stdout ?? ""); + expect(report.ok).toBe(false); + expect(report.scenarios[0].steps[0].failures.length).toBeGreaterThan(0); +}); diff --git a/e2e/client-bundle-strip.test.ts b/e2e/client-bundle-strip.test.ts index d2776dbb..cfc3ae1e 100644 --- a/e2e/client-bundle-strip.test.ts +++ b/e2e/client-bundle-strip.test.ts @@ -25,6 +25,8 @@ const cliEntry = resolve(repoRoot, "packages/cli/bin/pracht.js"); const SERVER_ONLY_MARKER = "SERVER_ONLY_STRIP_MARKER_7f3c"; const LOADER_BODY_MARKER = "LOADER_BODY_STRIP_MARKER_2a91"; const COMPONENT_MARKER = "COMPONENT_STRIP_MARKER_b55e"; +// Defined in examples/basic/src/server/notes-store.ts (used only by capabilities). +const CAPABILITY_SERVER_MARKER = "PRACHT_NOTES_STORE_SERVER_MARKER_4c8a"; test("server-only route exports and their imports are stripped from client bundles", async () => { test.setTimeout(120_000); @@ -110,6 +112,12 @@ test("server-only route exports and their imports are stripped from client bundl // silent broken build. expect(serverJs).toContain(LOADER_BODY_MARKER); expect(serverJs).toContain(SERVER_ONLY_MARKER); + + // Capability modules are server-only. examples/basic registers + // notes.search/notes.create via the manifest; their store marker must + // stay out of every client asset while remaining in the server bundle. + expect(clientJs).not.toContain(CAPABILITY_SERVER_MARKER); + expect(serverJs).toContain(CAPABILITY_SERVER_MARKER); } finally { rmSync(tempDir, { force: true, recursive: true }); } diff --git a/e2e/cloudflare-build.test.ts b/e2e/cloudflare-build.test.ts index d6f54293..789feeea 100644 --- a/e2e/cloudflare-build.test.ts +++ b/e2e/cloudflare-build.test.ts @@ -91,6 +91,15 @@ test("pracht build emits a deployable Cloudflare Worker setup", async () => { expect(deploySource).not.toContain("cssManifest"); expect(existsSync(resolve(exampleDir, "dist/client/_pracht/isg.json"))).toBe(true); + + // llms.txt lands in the static assets dir the worker serves via the ASSETS + // binding; dynamic SSR routes without static paths are not listed. + const llmsTxtPath = resolve(exampleDir, "dist/client/llms.txt"); + expect(existsSync(llmsTxtPath)).toBe(true); + const llmsTxt = readFileSync(llmsTxtPath, "utf-8"); + expect(llmsTxt.startsWith("# Pracht Cloudflare Example\n")).toBe(true); + expect(llmsTxt).toContain("- [/pricing](/pricing)"); + expect(llmsTxt).not.toContain("/products/:id"); }); test("prerendered SSG pages include client JS and working framework context", async () => { diff --git a/e2e/env-safety.test.ts b/e2e/env-safety.test.ts index d28f50e1..0430435a 100644 --- a/e2e/env-safety.test.ts +++ b/e2e/env-safety.test.ts @@ -99,8 +99,8 @@ test("envSafety allowlist lets an intentional env reference through", async () = writeFileSync( configPath, configSource.replace( - "pracht({ adapter: await resolveAdapter() })", - `pracht({ adapter: await resolveAdapter(), envSafety: { allow: [${JSON.stringify(LEAKED_ENV_VAR)}] } })`, + "adapter: await resolveAdapter(),", + `adapter: await resolveAdapter(),\n envSafety: { allow: [${JSON.stringify(LEAKED_ENV_VAR)}] },`, ), "utf-8", ); diff --git a/e2e/fixtures/failing.eval.json b/e2e/fixtures/failing.eval.json new file mode 100644 index 00000000..f352f441 --- /dev/null +++ b/e2e/fixtures/failing.eval.json @@ -0,0 +1,11 @@ +{ + "name": "deliberately failing scenario", + "task": "Used by e2e to prove pracht eval exits 1 on failed expectations.", + "steps": [ + { + "capability": "notes.search", + "input": { "query": "capabilities" }, + "expect": { "ok": false, "errorCode": "should_not_happen" } + } + ] +} diff --git a/e2e/fixtures/mini-capability-server.mjs b/e2e/fixtures/mini-capability-server.mjs new file mode 100644 index 00000000..5d806be7 --- /dev/null +++ b/e2e/fixtures/mini-capability-server.mjs @@ -0,0 +1,19 @@ +// Minimal capability-envelope server for the `pracht eval --start` e2e test. +// Starts listening after a short delay so the test exercises the wait loop. +import { createServer } from "node:http"; + +const port = Number(process.argv[2] ?? 3177); + +const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/api/capabilities/echo/ping") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, data: { pong: true } })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ ok: false, error: { code: "unknown_capability", message: "not found" } }), + ); +}); + +setTimeout(() => server.listen(port), 500); diff --git a/e2e/fixtures/start-flow.eval.json b/e2e/fixtures/start-flow.eval.json new file mode 100644 index 00000000..edf77c1b --- /dev/null +++ b/e2e/fixtures/start-flow.eval.json @@ -0,0 +1,11 @@ +{ + "name": "start flow", + "task": "Ping the echo capability on a server that pracht eval started itself.", + "steps": [ + { + "capability": "echo.ping", + "input": {}, + "expect": { "ok": true, "status": 200, "output": { "pong": true } } + } + ] +} diff --git a/e2e/llms-txt-dev.test.ts b/e2e/llms-txt-dev.test.ts new file mode 100644 index 00000000..d2f1e5e1 --- /dev/null +++ b/e2e/llms-txt-dev.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +// Runs against the pages-router example dev server, which enables the +// vite plugin's `llmsTxt` option (see examples/pages-router/vite.config.ts). + +test("GET /llms.txt serves the generated llms.txt in dev", async ({ request }) => { + const response = await request.get("/llms.txt"); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("text/plain"); + + const body = await response.text(); + expect(body.startsWith("# Pracht Pages Example\n")).toBe(true); + expect(body).toContain("## Pages"); + expect(body).toContain("- [/](/)"); + expect(body).toContain("- [/about](/about): supports `Accept: text/markdown`"); + + // Dynamic SSG routes are expanded through getStaticPaths(); the raw + // pattern must not appear. + expect(body).toContain("- [/blog/getting-started](/blog/getting-started)"); + expect(body).toContain("- [/blog/hello-world](/blog/hello-world)"); + expect(body).not.toContain("/blog/:slug"); + + expect(body).toContain("## API"); + expect(body).toContain("- [/api/health](/api/health): GET"); + expect(body).toContain("- [/api/me](/api/me): GET"); +}); + +test("llms.txt page ordering is stable", async ({ request }) => { + const body = await (await request.get("/llms.txt")).text(); + const paths = body.split("\n").flatMap((line) => { + const match = line.match(/^- \[([^\]]+)\]/); + return match && !match[1].startsWith("/api") ? [match[1]] : []; + }); + + const sorted = [...paths].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + expect(paths.length).toBeGreaterThan(3); + expect(paths).toEqual(sorted); +}); diff --git a/e2e/node-build.test.ts b/e2e/node-build.test.ts index 58badd8c..f67563fe 100644 --- a/e2e/node-build.test.ts +++ b/e2e/node-build.test.ts @@ -75,6 +75,38 @@ test("pracht build emits a deployable Node server entry", async () => { expect(productHtml).toContain("Price:"); } + // llms.txt is generated from the resolved app graph and served as a + // regular static file from dist/client. + const llmsTxtPath = resolve(exampleDir, "dist/client/llms.txt"); + expect(existsSync(llmsTxtPath)).toBe(true); + const llmsTxt = readFileSync(llmsTxtPath, "utf-8"); + expect(llmsTxt.startsWith("# Pracht Example\n")).toBe(true); + expect(llmsTxt).toContain("> Example app for the pracht framework."); + expect(llmsTxt).toContain("- [/](/): supports `Accept: text/markdown`"); + // Dynamic SSG instances are listed; the raw pattern is not. + expect(llmsTxt).toContain("- [/products/1](/products/1)"); + expect(llmsTxt).toContain("- [/products/3](/products/3)"); + expect(llmsTxt).not.toContain("/products/:productId"); + expect(llmsTxt).toContain("- [/api/echo](/api/echo): POST"); + expect(llmsTxt).toContain("- [/api/health](/api/health): GET"); + // HTTP-exposed capabilities are listed with their dispatch endpoint, + // effect class, and description; destructive ones note the confirmation. + expect(llmsTxt).toContain("## Capabilities"); + expect(llmsTxt).toContain( + "- [notes.search](/api/capabilities/notes/search): POST (read) — Find notes whose title or body matches the query.", + ); + expect(llmsTxt).toContain( + "- [notes.create](/api/capabilities/notes/create): POST (write) — Add a new note with a title and body.", + ); + expect(llmsTxt).toContain( + "- [notes.purge](/api/capabilities/notes/purge): POST (destructive, requires confirmation) — Permanently delete every note whose title starts with the prefix.", + ); + + const llmsTxtResponse = await fetch(`http://127.0.0.1:${port}/llms.txt`); + expect(llmsTxtResponse.status).toBe(200); + expect(llmsTxtResponse.headers.get("content-type")).toContain("text/plain"); + expect(await llmsTxtResponse.text()).toBe(llmsTxt); + const apiResponse = await fetch(`http://127.0.0.1:${port}/api/health`); expect(apiResponse.status).toBe(200); await expect(apiResponse.json()).resolves.toEqual({ status: "ok" }); @@ -181,11 +213,12 @@ test("precompileSsrJsx opt-in precompiles server JSX and keeps the app deployabl const distDir = resolve(exampleDir, "dist"); const viteConfig = readFileSync(viteConfigPath, "utf-8"); + expect(viteConfig).toContain("adapter: await resolveAdapter(),"); writeFileSync( viteConfigPath, viteConfig.replace( - "pracht({ adapter: await resolveAdapter() })", - "pracht({ adapter: await resolveAdapter(), precompileSsrJsx: true })", + "adapter: await resolveAdapter(),", + "adapter: await resolveAdapter(),\n precompileSsrJsx: true,", ), "utf-8", ); diff --git a/e2e/vercel-build.test.ts b/e2e/vercel-build.test.ts index e805e9e7..058084b3 100644 --- a/e2e/vercel-build.test.ts +++ b/e2e/vercel-build.test.ts @@ -46,6 +46,12 @@ test("pracht build emits a deployable Vercel Build Output setup", async () => { expect(existsSync(resolve(vercelDir, "static/_pracht/isg.json"))).toBe(false); expect(existsSync(resolve(exampleDir, "dist/client/_pracht/isg.json"))).toBe(false); + // llms.txt is copied into the Vercel static output alongside the other + // dist/client files and served by the `handle: filesystem` route. + const staticLlmsTxtPath = resolve(vercelDir, "static/llms.txt"); + expect(existsSync(staticLlmsTxtPath)).toBe(true); + expect(readFileSync(staticLlmsTxtPath, "utf-8")).toContain("# Pracht Example"); + const config = JSON.parse(readFileSync(configPath, "utf-8")); expect(config.version).toBe(3); expect(config.routes).toEqual( diff --git a/examples/basic/README.md b/examples/basic/README.md index 25e39c15..7564fc2b 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -11,3 +11,37 @@ before building to emit Vercel's `.vercel/output/` directory, or - `dist/client/` for static assets and prerendered HTML - `dist/server/server.js` as the server bundle - `node dist/server/server.js` runs the built Node server locally. + +## Agent surface + +The example registers five capabilities (`src/capabilities/`) around an +in-memory notes store, demoed by the `/notes` route and advertised in the +generated `/llms.txt`: + +- `notes.search` — read, exposed over HTTP and as a WebMCP page tool +- `notes.create` — write, HTTP +- `notes.purge` — destructive, HTTP with the prepare/commit confirmation flow +- `agent.whoami` — read, echoes the verified Web Bot Auth identity +- `agent.ping` — read, `agentPolicy: "require"` (answers verified agents only) + +The destructive flow needs a confirmation secret — without it, `notes.purge` +fails closed with `confirmation_unavailable`: + +```sh +PRACHT_CONFIRMATION_SECRET=dev-secret pnpm pracht dev +``` + +Try a capability, then run the scripted agent scenario in `evals/`: + +```sh +curl -s -X POST http://localhost:3000/api/capabilities/notes/search \ + -H 'content-type: application/json' -d '{"query":"capabilities"}' + +pnpm pracht eval --url http://localhost:3000 +``` + +Or let `pracht eval` manage the server itself: + +```sh +PRACHT_CONFIRMATION_SECRET=dev-secret pnpm pracht eval --start "pnpm pracht dev" +``` diff --git a/examples/basic/evals/notes.eval.json b/examples/basic/evals/notes.eval.json new file mode 100644 index 00000000..b0962d10 --- /dev/null +++ b/examples/basic/evals/notes.eval.json @@ -0,0 +1,32 @@ +{ + "name": "notes agent flow", + "task": "Search notes, hit a validation failure, then purge with the confirmation flow.", + "steps": [ + { + "capability": "notes.search", + "input": { "query": "capabilities" }, + "expect": { "ok": true, "status": 200 } + }, + { + "capability": "notes.search", + "input": { "query": "" }, + "expect": { "ok": false, "status": 400, "errorCode": "invalid_input" } + }, + { + "capability": "notes.create", + "input": { "title": "Purge target", "body": "Created by pracht eval." }, + "expect": { "ok": true, "output": { "note": { "title": "Purge target" } } } + }, + { + "capability": "notes.purge", + "input": { "titlePrefix": "Purge target" }, + "expect": { "ok": false, "status": 409, "errorCode": "confirmation_required" } + }, + { + "capability": "notes.purge", + "input": { "titlePrefix": "Purge target" }, + "confirm": "$steps[3].error.confirmationToken", + "expect": { "ok": true } + } + ] +} diff --git a/examples/basic/package.json b/examples/basic/package.json index f5d8bc89..4e088935 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -6,6 +6,7 @@ "dependencies": { "@pracht/adapter-node": "workspace:*", "@pracht/adapter-vercel": "workspace:*", + "@pracht/capabilities": "workspace:*", "@pracht/core": "workspace:*" }, "devDependencies": { diff --git a/examples/basic/src/api/dashboard.ts b/examples/basic/src/api/dashboard.ts index 53c73576..7d0ca670 100644 --- a/examples/basic/src/api/dashboard.ts +++ b/examples/basic/src/api/dashboard.ts @@ -1,3 +1,8 @@ -export async function POST() { +import type { BaseRouteArgs } from "@pracht/core"; + +export async function POST({ request }: BaseRouteArgs) { + if (new URL(request.url).searchParams.has("redirect")) { + return Response.redirect(new URL("/", request.url), 302); + } return Response.json({ saved: true }); } diff --git a/examples/basic/src/capabilities/agent-ping.ts b/examples/basic/src/capabilities/agent-ping.ts new file mode 100644 index 00000000..cbb5ae16 --- /dev/null +++ b/examples/basic/src/capabilities/agent-ping.ts @@ -0,0 +1,28 @@ +import { defineCapability } from "@pracht/capabilities"; + +// agentPolicy "require" overrides the app-wide "observe" default: unsigned or +// unverified requests to this endpoint get a 401 agent_required envelope. +export default defineCapability({ + title: "Agent ping", + description: "Answers only verified agents (Web Bot Auth required).", + input: { + type: "object", + properties: {}, + additionalProperties: false, + }, + output: { + type: "object", + properties: { + pong: { type: "boolean" }, + }, + required: ["pong"], + }, + effect: "read", + agentPolicy: "require", + expose: { + http: true, + }, + async run() { + return { pong: true }; + }, +}); diff --git a/examples/basic/src/capabilities/agent-whoami.ts b/examples/basic/src/capabilities/agent-whoami.ts new file mode 100644 index 00000000..ca0985a3 --- /dev/null +++ b/examples/basic/src/capabilities/agent-whoami.ts @@ -0,0 +1,42 @@ +import { defineCapability } from "@pracht/capabilities"; + +// Echoes the Web Bot Auth verification result so agents (and the e2e suite) +// can see what identity the server established. Policy stays "observe": +// unsigned callers are served and simply see verified: false. The default +// capability context already types `context.agent` — no custom context type. +export default defineCapability>({ + title: "Agent whoami", + description: "Report the verified Web Bot Auth agent identity for this request.", + input: { + type: "object", + properties: {}, + additionalProperties: false, + }, + output: { + type: "object", + properties: { + verified: { type: "boolean" }, + agentDomain: { type: "string" }, + keyId: { type: "string" }, + }, + required: ["verified"], + }, + effect: "read", + expose: { + http: true, + }, + async run({ context }) { + const agent = context.agent ?? null; + if (!agent) { + return { verified: false }; + } + const identity: { verified: boolean; agentDomain?: string; keyId: string } = { + verified: true, + keyId: agent.keyId, + }; + if (agent.agentDomain) { + identity.agentDomain = agent.agentDomain; + } + return identity; + }, +}); diff --git a/examples/basic/src/capabilities/notes-create.ts b/examples/basic/src/capabilities/notes-create.ts new file mode 100644 index 00000000..f8c8a85e --- /dev/null +++ b/examples/basic/src/capabilities/notes-create.ts @@ -0,0 +1,43 @@ +import { defineCapability } from "@pracht/capabilities"; +import { createNote } from "../server/notes-store.ts"; + +interface CreateInput { + title: string; + body: string; +} + +export default defineCapability({ + title: "Create note", + description: "Add a new note with a title and body.", + input: { + type: "object", + properties: { + title: { type: "string", minLength: 1, maxLength: 120 }, + body: { type: "string", default: "" }, + }, + required: ["title"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { + note: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + body: { type: "string" }, + }, + required: ["id", "title", "body"], + }, + }, + required: ["note"], + }, + effect: "write", + expose: { + http: true, + }, + async run({ input }) { + return { note: createNote(input.title, input.body) }; + }, +}); diff --git a/examples/basic/src/capabilities/notes-purge.ts b/examples/basic/src/capabilities/notes-purge.ts new file mode 100644 index 00000000..56b168e9 --- /dev/null +++ b/examples/basic/src/capabilities/notes-purge.ts @@ -0,0 +1,38 @@ +import { defineCapability } from "@pracht/capabilities"; +import { purgeNotes } from "../server/notes-store.ts"; + +interface PurgeInput { + titlePrefix: string; +} + +// Destructive demo capability: exposed over HTTP, which means every dispatch +// goes through the server-verified prepare/commit confirmation flow — the +// first call answers 409 confirmation_required with a token, and only a +// second call with identical input plus the x-pracht-confirm header runs. +// Requires PRACHT_CONFIRMATION_SECRET in the environment. +export default defineCapability({ + title: "Purge notes", + description: "Permanently delete every note whose title starts with the prefix.", + input: { + type: "object", + properties: { + titlePrefix: { type: "string", minLength: 3 }, + }, + required: ["titlePrefix"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { + purged: { type: "integer", minimum: 0 }, + }, + required: ["purged"], + }, + effect: "destructive", + expose: { + http: true, + }, + async run({ input }) { + return { purged: purgeNotes(input.titlePrefix) }; + }, +}); diff --git a/examples/basic/src/capabilities/notes-search.ts b/examples/basic/src/capabilities/notes-search.ts new file mode 100644 index 00000000..ee7db04f --- /dev/null +++ b/examples/basic/src/capabilities/notes-search.ts @@ -0,0 +1,47 @@ +import { defineCapability } from "@pracht/capabilities"; +import { searchNotes } from "../server/notes-store.ts"; + +interface SearchInput { + query: string; + limit: number; +} + +export default defineCapability({ + title: "Search notes", + description: "Find notes whose title or body matches the query.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1, description: "Text to search for." }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { + notes: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + body: { type: "string" }, + }, + required: ["id", "title", "body"], + }, + }, + }, + required: ["notes"], + }, + effect: "read", + expose: { + http: true, + webmcp: true, + }, + async run({ input }) { + return { notes: searchNotes(input.query, input.limit) }; + }, +}); diff --git a/examples/basic/src/pracht-capabilities.d.ts b/examples/basic/src/pracht-capabilities.d.ts new file mode 100644 index 00000000..620fedf7 --- /dev/null +++ b/examples/basic/src/pracht-capabilities.d.ts @@ -0,0 +1,31 @@ +// Generated by `pracht typegen`. Do not edit manually. +import "@pracht/core"; + +declare module "@pracht/core" { + interface Register { + capabilities: { + "notes.search": { + input: { "query": string; "limit"?: number; }; + output: { "notes": Array<{ "id": string; "title": string; "body": string; [key: string]: unknown; }>; [key: string]: unknown; }; + }; + "notes.create": { + input: { "title": string; "body"?: string; }; + output: { "note": { "id": string; "title": string; "body": string; [key: string]: unknown; }; [key: string]: unknown; }; + }; + "notes.purge": { + input: { "titlePrefix": string; }; + output: { "purged": number; [key: string]: unknown; }; + }; + "agent.whoami": { + input: Record; + output: { "verified": boolean; "agentDomain"?: string; "keyId"?: string; [key: string]: unknown; }; + }; + "agent.ping": { + input: Record; + output: { "pong": boolean; [key: string]: unknown; }; + }; + }; + } +} + +export {}; diff --git a/examples/basic/src/routes.ts b/examples/basic/src/routes.ts index ec54df07..a181b744 100644 --- a/examples/basic/src/routes.ts +++ b/examples/basic/src/routes.ts @@ -12,6 +12,26 @@ export const app = defineApp({ component: () => import("./routes/not-found.tsx"), shell: "public", }, + capabilities: { + "notes.search": () => import("./capabilities/notes-search.ts"), + "notes.create": () => import("./capabilities/notes-create.ts"), + "notes.purge": () => import("./capabilities/notes-purge.ts"), + "agent.whoami": () => import("./capabilities/agent-whoami.ts"), + "agent.ping": () => import("./capabilities/agent-ping.ts"), + }, + agents: { + // Web Bot Auth: verify RFC 9421 agent signatures and surface the identity + // as `context.agent`. The key below is the e2e suite's test agent — a + // *public* Ed25519 key, safe to commit. "observe" serves unsigned callers + // too; `agent.ping` opts into "require" per capability. + webBotAuth: { + policy: "observe", + keys: [{ x: "s5n91rPm5ymJjl--scT4WWq7HE9kUdj-6sVe5r__xgc", agent: "test-agent.example" }], + }, + confirmation: { + ttlSeconds: 120, + }, + }, routes: [ group({ shell: "public" }, [ route("/", () => import("./routes/home.tsx"), { @@ -19,6 +39,7 @@ export const app = defineApp({ render: "ssg", speculation: "prefetch", }), + route("/notes", () => import("./routes/notes.tsx"), { id: "notes", render: "ssr" }), route("/products/:productId", () => import("./routes/product.tsx"), { id: "product", render: "ssg", diff --git a/examples/basic/src/routes/home.tsx b/examples/basic/src/routes/home.tsx index 8eddf60f..2a4b0ded 100644 --- a/examples/basic/src/routes/home.tsx +++ b/examples/basic/src/routes/home.tsx @@ -1,5 +1,16 @@ import type { LoaderArgs, RouteComponentProps } from "@pracht/core"; +// Served when a client requests `Accept: text/markdown` (Markdown-for-Agents); +// llms.txt flags this route as markdown-capable. +export const markdown = `# Pracht Example + +Pracht starts with an explicit app manifest. + +- Hybrid route manifest +- Per-route rendering modes +- Thin deployment adapters +`; + export async function loader(_args: LoaderArgs) { return { highlights: ["Hybrid route manifest", "Per-route rendering modes", "Thin deployment adapters"], diff --git a/examples/basic/src/routes/notes.tsx b/examples/basic/src/routes/notes.tsx new file mode 100644 index 00000000..1edeeed0 --- /dev/null +++ b/examples/basic/src/routes/notes.tsx @@ -0,0 +1,60 @@ +import { useState } from "preact/hooks"; +import { Form, invokeCapability, useIsHydrated, useRouteData, type LoaderArgs } from "@pracht/core"; + +// Direct server-side invocation: the loader calls the same capability the +// HTTP projection serves, through the same validation + middleware pipeline. +// Input and output types are inferred from the capability name — they come +// from src/pracht-capabilities.d.ts, generated by `pracht typegen`. +export async function loader({ request, context, signal }: LoaderArgs) { + const result = await invokeCapability( + "notes.search", + { query: "note" }, + { request, context, signal }, + ); + + return { + notes: result.ok ? result.data.notes : [], + error: result.ok ? null : result.error.message, + }; +} + +export function Component() { + const data = useRouteData(); + const hydrated = useIsHydrated(); + const [status, setStatus] = useState(null); + + return ( +
+

Notes

+
    + {data.notes.map((note) => ( +
  • + {note.title} — {note.body} +
  • + ))} +
+ {/* + The form posts to the exact endpoint agents call — one contract for + both users. Fields are coerced onto the capability's input schema + server-side, the note list revalidates automatically after a + successful submission, and without JavaScript the endpoint accepts + the form-encoded post and redirects back to this page. + */} + { + setStatus( + result.ok ? `Created "${result.data.note.title}"` : `Error: ${result.error.message}`, + ); + }} + > + + + + + {status ?

{status}

: null} +
+ ); +} diff --git a/examples/basic/src/server/notes-store.ts b/examples/basic/src/server/notes-store.ts new file mode 100644 index 00000000..02e281be --- /dev/null +++ b/examples/basic/src/server/notes-store.ts @@ -0,0 +1,46 @@ +// In-memory notes backing both capabilities. Server-only — this marker must +// never appear in client bundles (asserted by e2e/client-bundle-strip.test.ts). +export const NOTES_STORE_SERVER_MARKER = "PRACHT_NOTES_STORE_SERVER_MARKER_4c8a"; + +export interface Note { + id: string; + title: string; + body: string; +} + +const notes: Note[] = [ + { id: "n1", title: "Manifest routing", body: "A note on explicit defineApp() wiring." }, + { id: "n2", title: "Render modes", body: "A note on SPA, SSR, SSG, and ISG per route." }, + { id: "n3", title: "Capabilities", body: "This note is served by a typed capability." }, +]; + +export function searchNotes(query: string, limit: number): Note[] { + if (!Array.isArray(notes)) { + throw new Error(NOTES_STORE_SERVER_MARKER); + } + const needle = query.toLowerCase(); + return notes + .filter( + (note) => + note.title.toLowerCase().includes(needle) || note.body.toLowerCase().includes(needle), + ) + .slice(0, limit); +} + +export function createNote(title: string, body: string): Note { + const note: Note = { id: `n${notes.length + 1}-${Date.now().toString(36)}`, title, body }; + notes.push(note); + return note; +} + +/** Delete notes whose title starts with the prefix. Returns the count removed. */ +export function purgeNotes(titlePrefix: string): number { + let purged = 0; + for (let index = notes.length - 1; index >= 0; index -= 1) { + if (notes[index].title.startsWith(titlePrefix)) { + notes.splice(index, 1); + purged += 1; + } + } + return purged; +} diff --git a/examples/basic/src/shells/public.tsx b/examples/basic/src/shells/public.tsx index 43898f2f..882e2488 100644 --- a/examples/basic/src/shells/public.tsx +++ b/examples/basic/src/shells/public.tsx @@ -7,6 +7,7 @@ export function Shell({ children }: ShellProps) { Pracht diff --git a/examples/basic/vite.config.ts b/examples/basic/vite.config.ts index ca33857b..a862913d 100644 --- a/examples/basic/vite.config.ts +++ b/examples/basic/vite.config.ts @@ -17,5 +17,13 @@ async function resolveAdapter() { } export default defineConfig(async () => ({ - plugins: [pracht({ adapter: await resolveAdapter() })], + plugins: [ + pracht({ + adapter: await resolveAdapter(), + llmsTxt: { + title: "Pracht Example", + description: "Example app for the pracht framework.", + }, + }), + ], })); diff --git a/examples/cloudflare/vite.config.ts b/examples/cloudflare/vite.config.ts index 2a8fcf0c..51266b17 100644 --- a/examples/cloudflare/vite.config.ts +++ b/examples/cloudflare/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ workerExportsFrom: "/src/cloudflare.ts", cache: true, }), + llmsTxt: { title: "Pracht Cloudflare Example" }, }), ], }); diff --git a/examples/docs/src/routes.ts b/examples/docs/src/routes.ts index 7471ec49..1def4c20 100644 --- a/examples/docs/src/routes.ts +++ b/examples/docs/src/routes.ts @@ -84,6 +84,10 @@ export const app = defineApp({ id: "performance", render: "ssg", }), + route("/docs/agents", () => import("./routes/docs/agents.md"), { + id: "agents", + render: "ssg", + }), route("/docs/llms", () => import("./routes/docs/llms.md"), { id: "llms", render: "ssg", @@ -96,6 +100,14 @@ export const app = defineApp({ id: "agent-skills", render: "ssg", }), + route("/docs/capabilities", () => import("./routes/docs/capabilities.md"), { + id: "capabilities", + render: "ssg", + }), + route("/docs/agent-trust", () => import("./routes/docs/agent-trust.md"), { + id: "agent-trust", + render: "ssg", + }), route("/docs/recipes/i18n", () => import("./routes/docs/recipes-i18n.md"), { id: "recipes-i18n", render: "ssg", diff --git a/examples/docs/src/routes/docs/agent-skills.md b/examples/docs/src/routes/docs/agent-skills.md index 5337d4c1..b98a0d5e 100644 --- a/examples/docs/src/routes/docs/agent-skills.md +++ b/examples/docs/src/routes/docs/agent-skills.md @@ -6,8 +6,8 @@ prev: href: /docs/agent-workflow title: AI-Assisted Authoring & Review next: - href: /docs/recipes/i18n - title: i18n + href: /docs/capabilities + title: Capabilities --- ## What Ships diff --git a/examples/docs/src/routes/docs/agent-trust.md b/examples/docs/src/routes/docs/agent-trust.md new file mode 100644 index 00000000..b2bef5e0 --- /dev/null +++ b/examples/docs/src/routes/docs/agent-trust.md @@ -0,0 +1,137 @@ +--- +title: Agent Trust +lead: Who is calling, may they do this, and what happened? Verified agent identity with Web Bot Auth, a prepare/commit confirmation flow for destructive operations, structured audit events, and pracht eval to prove agent flows in CI. +breadcrumb: Agent Trust +prev: + href: /docs/capabilities + title: Capabilities +next: + href: /docs/recipes/i18n + title: i18n +--- + +## Three Questions + +Exposing [capabilities](/docs/capabilities) to agents raises questions a schema cannot answer. The agent trust layer answers all three, and everything is opt-in — an app without `defineApp({ agents })` and without destructive capabilities pays a single property check per request. + +- **Who is calling?** — Web Bot Auth puts a cryptographically verified agent identity on the request context. +- **May they do this?** — policy modes per app and per capability, plus a server-verified confirmation flow for destructive effects. +- **What happened?** — one structured audit event per capability dispatch. + +--- + +## Web Bot Auth: Verified Agent Identity + +Agents sign requests with [RFC 9421 HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) and publish Ed25519 public keys in a well-known directory — the emerging standard already deployed by major CDNs. pracht implements the verifier side; configuration lives in the manifest, and keys are public, so they are safe there: + +```ts [src/routes.ts] +export const app = defineApp({ + agents: { + webBotAuth: { + policy: "observe", // identify agents, serve everyone + keys: [{ x: "", agent: "my-agent.example" }], + directories: ["https://signature-agent.cloudflare.com"], // allowlist-only key fetching + }, + }, +}); +``` + +Verification happens once per request in `handlePrachtRequest`, using only Web platform APIs — Node, Cloudflare, and Vercel share the implementation. The result surfaces everywhere: + +```ts [src/capabilities/agent-whoami.ts] +async run({ context }) { + context.agent; // { verified: true, agentDomain, keyId } | null +} +``` + +Verification fails closed: expired windows, uncovered components, unknown keys, or non-allowlisted directories all yield `context.agent = null`, never a partial identity. + +--- + +## Policy Modes + +`"observe"` identifies agents without blocking anyone — use it to roll out and audit. `"require"` answers unsigned requests to capability HTTP endpoints with a typed `401 agent_required` envelope. The app default can be tightened per capability: + +```ts [src/capabilities/agent-ping.ts] +export default defineCapability({ + // ... + agentPolicy: "require", // this endpoint answers only verified agents +}); +``` + +--- + +## Destructive Capabilities: Prepare/Commit + +Capabilities declaring `effect: "destructive"` (delete, publish, pay, send) may be exposed over HTTP only, and every dispatch is confirmation-gated. Set `PRACHT_CONFIRMATION_SECRET` in the server environment; without it, destructive calls fail closed. + +The first call never runs the capability — it answers with a short-lived token: + +```jsonc +// POST /api/capabilities/notes/purge { "titlePrefix": "Old" } +// → 409 +{ + "ok": false, + "error": { + "code": "confirmation_required", + "confirmationToken": "v1..", + "expiresAt": 1735689720 + } +} +``` + +The token is an HMAC over the caller's principal (verified agent key, or `"anonymous"`), the capability name, the canonicalized input, and an expiry. Committing means repeating the call with identical input plus the `x-pracht-confirm` header — tampered, expired, different-input, or different-principal tokens are rejected with `403`, fail closed. + +Agent hosts cannot yet be trusted to carry this two-step flow faithfully, so destructive capabilities cannot be exposed over WebMCP — `defineCapability()`, the runtime, and `pracht verify` all enforce it. + +--- + +## Audit Trail + +Every capability dispatch — HTTP or direct `invokeCapability()` — emits one structured event with the capability name, effect, transport, outcome, status, latency, and the verified agent identity (or `null`): + +```ts [src/server/audit.ts] +import { setCapabilityAuditHook } from "@pracht/core"; + +setCapabilityAuditHook((event) => log.info("capability", event)); +``` + +Hook exceptions are swallowed — auditing observes, it never breaks a request. + +--- + +## pracht eval: Prove Agent Flows in CI + +Can an agent actually complete a task through your capabilities? `pracht eval` runs scripted scenarios against the HTTP projection and exits 1 on any failed expectation: + +```jsonc [evals/notes.eval.json] +{ + "name": "notes agent flow", + "steps": [ + { "capability": "notes.search", "input": { "query": "roadmap" } }, + { + "capability": "notes.purge", + "input": { "titlePrefix": "Old" }, + "expect": { "status": 409, "errorCode": "confirmation_required" } + }, + { + "capability": "notes.purge", + "input": { "titlePrefix": "Old" }, + "confirm": "$steps[1].error.confirmationToken", + "expect": { "ok": true, "output": { "purged": 1 } } + } + ] +} +``` + +`$steps[n].` references carry values between steps — the `confirm` field above threads the prepare/commit flow through a scenario without spelling out the header name. One command runs it — `--start` launches your app, waits for it to answer, runs the scenarios, and stops it: + +```sh +pracht eval --start "pracht preview" # runs evals/**/*.eval.json + +# …or manage the server yourself: +pracht preview # in another terminal +pracht eval --url http://localhost:3000 +``` + +The [Testing recipe](/docs/recipes/testing) covers the rest of the agent-surface toolbox: unit testing the full dispatch pipeline with `createCapabilityTestHost()` — including this confirmation flow and simulated agent identities — plus Playwright patterns, faking the WebMCP API, and signing Web Bot Auth requests in tests. diff --git a/examples/docs/src/routes/docs/agents.md b/examples/docs/src/routes/docs/agents.md new file mode 100644 index 00000000..33de3471 --- /dev/null +++ b/examples/docs/src/routes/docs/agents.md @@ -0,0 +1,121 @@ +--- +title: The Agentic Web +lead: The web has two users now — people, and the agents acting on their behalf. pracht projects one explicit app graph to both — components for humans, typed and trust-gated tools for agents, with discovery, identity, confirmation, audit, and CI proof built in. +breadcrumb: Agents +prev: + href: /docs/performance + title: Performance +next: + href: /docs/llms + title: LLM Content +--- + +## The Web Has Two Users Now + +Today, when an AI agent needs to do something on a website — book a slot, file a ticket, buy the thing — it does what a scraper does: load the page, read the DOM, guess which ` +; +``` + +Mutations keep the page honest automatically: capabilities are effect-classed, so after any successful non-`read` call from the browser (`callCapability` or `
`) the active route's loader data revalidates — no manual `revalidate()` bookkeeping. Opt out per call with `{ revalidate: false }`. + +Over HTTP — every response uses a typed envelope, with path-scoped validation issues an agent can act on: + +```sh +curl -X POST /api/capabilities/notes/search -H 'content-type: application/json' -d '{"query":"roadmap"}' +# { "ok": true, "data": { "notes": [...] } } +# { "ok": false, "error": { "code": "invalid_input", "issues": [{ "path": "/limit", "message": "must be <= 20" }] } } +``` + +And both calls above are fully typed: `pracht typegen` generates input/output types from the capability schemas into `src/pracht-capabilities.d.ts`, so `invokeCapability()` and `callCapability()` infer both sides from the capability name — no per-call generics. + +--- + +## WebMCP: Tools for In-Browser Agents + +With `expose.webmcp: true`, the client runtime registers the capability as a [WebMCP](https://developer.chrome.com/docs/ai/webmcp) page tool via `document.modelContext.registerTool()` (Chrome origin trial, with the deprecated `navigator.modelContext` fallback). The tool's `execute()` dispatches through the HTTP projection, so the agent acts as the signed-in user in their tab while validation, middleware, and policy all stay server-side. + +The shim ships as its own chunk behind feature detection: browsers without the API never download it, apps without webmcp-exposed capabilities never reference it, and it works in both full-hydration and islands modes. + +--- + +## Private by Default + +- A capability without `expose` is never reachable over the network. +- Exposure requires a complete contract — `pracht verify` fails for exposed capabilities missing a description, schema, or effect class. +- `destructive` capabilities are gated by a server-verified confirmation flow and cannot be exposed to agent projections — see [Agent Trust](/docs/agent-trust). +- Output is validated too: a handler returning data outside its output schema produces a redacted 500, never the raw value. +- HTTP-exposed capabilities are listed in the generated [`/llms.txt`](/docs/llms) with their endpoint, effect class, and description, so agents can discover them without scraping. + +--- + +## Inspect the Graph + +The capability graph feeds every inspection surface: the `pracht dev` startup banner, `pracht inspect capabilities [--json]`, the `/_pracht` devtools page, the `inspect_capabilities` tool on the `pracht mcp` server, and the static checks in `pracht verify`. + +```sh +pracht inspect capabilities +# notes.search read http,webmcp /api/capabilities/notes/search +# notes.create write http /api/capabilities/notes/create +``` + +Coming next: a remote MCP endpoint (`/mcp`) projecting the same capabilities to out-of-browser agents, and MCP Apps UI views rendered with Preact. + +For the story behind the design, read [The Agentic Web](/docs/agents); for unit, E2E, and WebMCP testing patterns, see the [Testing recipe](/docs/recipes/testing). diff --git a/examples/docs/src/routes/docs/cli.md b/examples/docs/src/routes/docs/cli.md index feb66c26..ebd7309b 100644 --- a/examples/docs/src/routes/docs/cli.md +++ b/examples/docs/src/routes/docs/cli.md @@ -18,11 +18,13 @@ Starts the Vite dev server with SSR middleware, HMR, and instant feedback. pracht dev # Custom port -PORT=4000 pracht dev +pracht dev --port 4000 # or PORT=4000 pracht dev ``` Routes are rendered server-side on each request. Changes to routes, shells, loaders, and components are reflected immediately via HMR. +The startup banner prints the resolved app graph: every route with its render mode, shell, and middleware, every API endpoint with its methods, and — when the app registers any — every [capability](/docs/capabilities) with its effect class, exposure, and dispatch path. + --- ## pracht build diff --git a/examples/docs/src/routes/docs/llms.md b/examples/docs/src/routes/docs/llms.md index 8ae9ff00..3b06526b 100644 --- a/examples/docs/src/routes/docs/llms.md +++ b/examples/docs/src/routes/docs/llms.md @@ -3,8 +3,8 @@ title: LLM Content Negotiation lead: Give AI agents first-class Markdown at the same URLs your readers use. pracht routes can negotiate on Accept: text/markdown, publish /llms.txt, and keep HTML as the browser default. breadcrumb: LLMs prev: - href: /docs/performance - title: Performance + href: /docs/agents + title: The Agentic Web next: href: /docs/agent-workflow title: AI-Assisted Authoring & Review @@ -71,7 +71,18 @@ This makes the feature safe to enable on public pages: humans get the polished a ## Discovery with llms.txt -Content negotiation is paired with two discovery files in this example app: +pracht can generate `/llms.txt` for you: the vite plugin's `llmsTxt` option emits the file from the resolved app graph — every page URL, every API endpoint with its methods, and every HTTP-exposed [capability](/docs/capabilities) with its dispatch endpoint, effect class, and description. Routes with a `markdown` export are annotated with `` supports `Accept: text/markdown` ``. + +```ts [vite.config.ts] +pracht({ + adapter: nodeAdapter(), + llmsTxt: { origin: "https://example.com" }, // title/description default to package.json +}); +``` + +`pracht build` writes `dist/client/llms.txt` and the dev server serves it live at `/llms.txt`. + +This docs site needs curated sections and an `llms-full.txt` bundle with inlined page content, so it uses a custom frontmatter-driven plugin instead: - `/llms.txt` — a concise map of the docs with titles, descriptions, and canonical URLs. - `/llms-full.txt` — a single Markdown bundle with the full source of every listed page. diff --git a/examples/docs/src/routes/docs/performance.md b/examples/docs/src/routes/docs/performance.md index 7ebbca18..c130ee38 100644 --- a/examples/docs/src/routes/docs/performance.md +++ b/examples/docs/src/routes/docs/performance.md @@ -6,8 +6,8 @@ prev: href: /docs/prefetching title: Prefetching next: - href: /docs/llms - title: LLM Content Negotiation + href: /docs/agents + title: The Agentic Web --- ## Route-Level Code Splitting diff --git a/examples/docs/src/routes/docs/recipes-i18n.md b/examples/docs/src/routes/docs/recipes-i18n.md index 8d566468..4d2d560f 100644 --- a/examples/docs/src/routes/docs/recipes-i18n.md +++ b/examples/docs/src/routes/docs/recipes-i18n.md @@ -3,8 +3,8 @@ title: Internationalization (i18n) lead: Serve your app in multiple languages using middleware for locale detection, loaders for translated content, and context for passing the active locale through your app. breadcrumb: i18n prev: - href: /docs/agent-skills - title: Agent Skills + href: /docs/agent-trust + title: Agent Trust next: href: /docs/recipes/auth title: Authentication diff --git a/examples/docs/src/routes/docs/recipes-testing.md b/examples/docs/src/routes/docs/recipes-testing.md index 67727bf5..f3571dc3 100644 --- a/examples/docs/src/routes/docs/recipes-testing.md +++ b/examples/docs/src/routes/docs/recipes-testing.md @@ -1,6 +1,6 @@ --- title: Testing -lead: Test your pracht app at every level — unit test loaders and API routes with Vitest, and run full E2E tests with Playwright to verify rendering, navigation, and hydration. +lead: Test your pracht app at every level — unit test loaders and API routes with Vitest, run full E2E tests with Playwright to verify rendering, navigation, and hydration, and prove your agent surfaces with capability tests and pracht eval. breadcrumb: Testing prev: href: /docs/recipes/view-transitions @@ -390,6 +390,249 @@ test("loader returns JSON for client navigation requests", async ({ request }) = --- +## Testing Capabilities & Agent Surfaces + +[Capabilities](/docs/capabilities) are testable at three levels: unit test the `run()` function, E2E test the HTTP projection, and script whole agent flows with [`pracht eval`](/docs/agent-trust). + +### Unit testing run() + +A capability module's default export carries its `run()` function — call it directly to test the business logic: + +```ts [src/capabilities/notes-search.test.ts] +import { describe, it, expect } from "vitest"; +import notesSearch from "./notes-search"; + +describe("notes.search", () => { + it("finds notes matching the query", async () => { + const result = await notesSearch.run({ + input: { query: "roadmap", limit: 10 }, + context: {}, + request: new Request("http://localhost/api/capabilities/notes/search"), + signal: AbortSignal.timeout(5000), + }); + + expect(result.notes.length).toBeGreaterThan(0); + }); + + it("rejects out-of-range input", () => { + const result = notesSearch.validateInput({ query: "roadmap", limit: 99 }); + expect(result).toEqual({ + ok: false, + issues: [{ path: "/limit", message: "must be <= 20" }], + }); + }); +}); +``` + +The object `defineCapability()` returns also carries `validateInput()` / `validateOutput()` — the exact validators the dispatch pipeline uses, including schema defaults — so contract behavior is unit-testable without a server. + +Note the boundary: calling `run()` directly skips validation, the middleware chain, and the confirmation flow. For those, build a test host. + +### The full pipeline without a server + +`createCapabilityTestHost()` runs the real dispatch pipeline in-process — no manifest, no Vite, no port. `invoke()` mirrors `invokeCapability()`; `request()` mirrors the generated HTTP endpoints, including agent policy and the confirmation flow: + +```ts [src/capabilities/notes.test.ts] +import { CONFIRMATION_HEADER, createCapabilityTestHost, setCapabilityConfirmationSecret } from "@pracht/core"; +import notesSearch from "./notes-search"; +import notesPurge from "./notes-purge"; + +const host = createCapabilityTestHost({ + capabilities: { "notes.search": notesSearch, "notes.purge": notesPurge }, + middleware: { auth: authMiddleware }, // for capabilities declaring middleware: ["auth"] +}); + +it("runs validation, middleware, run(), and output validation", async () => { + const result = await host.invoke("notes.search", { query: "roadmap" }); + expect(result.ok).toBe(true); +}); + +it("walks the prepare/commit confirmation flow", async () => { + setCapabilityConfirmationSecret("test-only-secret"); + + const prepare = await host.request("notes.purge", { titlePrefix: "Old" }); + expect(prepare.status).toBe(409); + const { error } = await prepare.json(); + + const commit = await host.request("notes.purge", { titlePrefix: "Old" }, { + headers: { [CONFIRMATION_HEADER]: error.confirmationToken }, + }); + expect(commit.status).toBe(200); +}); +``` + +To test `agentPolicy: "require"` and `context.agent`, inject a simulated verified identity — no request signing needed: + +```ts +const response = await host.request("agent.ping", {}, { + agent: { verified: true, agentDomain: "test-agent.example", keyId: "test-key" }, +}); +expect(response.status).toBe(200); +``` + +### E2E testing the HTTP projection + +Every exposed capability answers at `POST /api/capabilities/` with a typed envelope, which makes Playwright request tests precise: + +```ts [e2e/capabilities.test.ts] +import { test, expect } from "@playwright/test"; + +test("capability answers with the ok envelope", async ({ request }) => { + const response = await request.post("/api/capabilities/notes/search", { + data: { query: "roadmap" }, + }); + + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(Array.isArray(body.data.notes)).toBe(true); +}); + +test("invalid input returns path-scoped issues", async ({ request }) => { + const response = await request.post("/api/capabilities/notes/search", { + data: { query: "", limit: 99 }, + }); + + expect(response.status()).toBe(400); + const body = await response.json(); + expect(body.error.code).toBe("invalid_input"); + expect(body.error.issues).toEqual([ + { path: "/query", message: "must be at least 1 character(s) long" }, + { path: "/limit", message: "must be <= 20" }, + ]); +}); +``` + +### Testing the destructive confirmation flow + +`destructive` capabilities need `PRACHT_CONFIRMATION_SECRET` in the server environment — set it on Playwright's `webServer` so the flow works in CI: + +```ts [playwright.config.ts] +webServer: { + command: "pnpm dev", + port: 3000, + env: { PRACHT_CONFIRMATION_SECRET: "test-only-secret" }, +}, +``` + +Then assert the prepare/commit handshake — the first call must not run the capability: + +```ts [e2e/confirmation.test.ts] +import { CONFIRMATION_HEADER } from "@pracht/capabilities"; + +test("destructive capability requires confirmation, then commits", async ({ request }) => { + // Prepare: no token → 409 with a confirmation token, nothing deleted. + const prepare = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "Old" }, + }); + expect(prepare.status()).toBe(409); + const { error } = await prepare.json(); + expect(error.code).toBe("confirmation_required"); + + // Commit: identical input + the token → runs. + const commit = await request.post("/api/capabilities/notes/purge", { + data: { titlePrefix: "Old" }, + headers: { [CONFIRMATION_HEADER]: error.confirmationToken }, + }); + expect(commit.status()).toBe(200); +}); +``` + +Worth asserting too: a tampered token and a same-token-different-input call both answer `403`. + +### Faking WebMCP in the browser + +No agent is needed to test the [WebMCP projection](/docs/capabilities). Install a fake `document.modelContext` before any page script runs — the client runtime's feature detection will register tools against it, and `execute()` round-trips through the real HTTP projection: + +```ts [e2e/webmcp.test.ts] +test("webmcp tools register and execute", async ({ page }) => { + await page.addInitScript(() => { + const registered: unknown[] = []; + (window as any).__webmcpTools = registered; + (document as any).modelContext = { + registerTool: (tool: unknown) => (registered.push(tool), Promise.resolve()), + }; + }); + + await page.goto("/notes"); + await page.waitForFunction(() => (window as any).__webmcpTools?.length); + + const result = await page.evaluate(() => { + const tool = (window as any).__webmcpTools.find((t: any) => t.name === "notes.search"); + return tool.execute({ query: "roadmap" }); + }); + + const envelope = JSON.parse(result.content[0].text); + expect(envelope.ok).toBe(true); +}); +``` + +### Signing Web Bot Auth requests in tests + +The test host's `agent` option covers pipeline behavior; to test the *verifier itself* over the wire, sign requests the way a real agent would. Generate an Ed25519 test keypair, put the *public* JWK in your manifest's `agents.webBotAuth.keys`, and sign with the private part in tests: + +```ts [e2e/web-bot-auth.ts] +import { createPrivateKey, sign } from "node:crypto"; + +// Test-only keypair; the public `x` half lives in defineApp({ agents }). +const TEST_AGENT_JWK = { kty: "OKP", crv: "Ed25519", d: "", x: "" }; +const KEY_ID = ""; + +export function webBotAuthHeaders(authority: string): Record { + const now = Math.floor(Date.now() / 1000); + const signatureAgent = '"https://test-agent.example"'; + const params = + `("@authority" "signature-agent");created=${now};expires=${now + 300}` + + `;keyid="${KEY_ID}";alg="ed25519";tag="web-bot-auth"`; + const base = [ + `"@authority": ${authority}`, + `"signature-agent": ${signatureAgent}`, + `"@signature-params": ${params}`, + ].join("\n"); + + const key = createPrivateKey({ key: TEST_AGENT_JWK, format: "jwk" }); + const signature = sign(null, Buffer.from(base, "utf-8"), key); + + return { + "signature-agent": signatureAgent, + "signature-input": `sig1=${params}`, + signature: `sig1=:${signature.toString("base64")}:`, + }; +} +``` + +```ts [e2e/agent-identity.test.ts] +test("verified agents pass agentPolicy: require", async ({ request }) => { + const response = await request.post("/api/capabilities/agent/ping", { + data: {}, + headers: webBotAuthHeaders("localhost:3000"), + }); + expect(response.status()).toBe(200); +}); + +test("unsigned requests are rejected", async ({ request }) => { + const response = await request.post("/api/capabilities/agent/ping", { data: {} }); + expect(response.status()).toBe(401); + expect((await response.json()).error.code).toBe("agent_required"); +}); +``` + +### Scripted agent flows with pracht eval + +`pracht eval` runs multi-step scenarios against a live server and exits `1` on any failed expectation — regression tests for your agent UX. Scenarios live in `evals/**/*.eval.json`; `$steps[n].` references thread values (like confirmation tokens) between steps: + +```sh +# One command: start the app, wait for it, run the scenarios, stop it. +pracht eval --start "pracht preview" # add --json for machine-readable CI output + +# Or point at a server you manage yourself: +pracht eval --url http://localhost:3000 +``` + +See [Agent Trust](/docs/agent-trust) for the scenario format, and the framework repository's `examples/basic` for a complete worked example — five capabilities with unit, E2E, and eval coverage. + +--- + ## Vitest Configuration A minimal `vitest.config.ts` for a pracht app: diff --git a/examples/docs/src/routes/home.tsx b/examples/docs/src/routes/home.tsx index 84df5230..3db4e9fe 100644 --- a/examples/docs/src/routes/home.tsx +++ b/examples/docs/src/routes/home.tsx @@ -74,6 +74,10 @@ const AGENT_DOC_LINKS: { href: string; text: string }[] = [ href: "/docs/adapters", text: "Adapters — Cloudflare Workers, Vercel Edge Functions, and Node.js", }, + { + href: "/docs/agents", + text: "The agentic web — why pracht projects one app graph to humans and agents: capabilities, trust, and discovery", + }, { href: "/docs/llms", text: "LLM content negotiation — markdown on the same URLs as HTML, plus /llms.txt", @@ -82,6 +86,14 @@ const AGENT_DOC_LINKS: { href: string; text: string }[] = [ href: "/docs/agent-skills", text: "Agent skills — installable Claude Code skills with a /.well-known discovery manifest", }, + { + href: "/docs/capabilities", + text: "Capabilities — typed operations defined once and projected to server calls, HTTP endpoints, and WebMCP page tools", + }, + { + href: "/docs/agent-trust", + text: "Agent trust — Web Bot Auth verified identity, confirmation flow for destructive operations, and pracht eval", + }, { href: "/docs/demo-comparison", text: "Demo comparison — product + agent demo highlighting pracht's strengths", @@ -104,7 +116,7 @@ const AGENT_DOC_LINKS: { href: string; text: string }[] = [ }, { href: "/docs/recipes/testing", - text: "Recipe: testing — Vitest for loaders and API routes, Playwright for E2E", + text: "Recipe: testing — Vitest for loaders and API routes, Playwright for E2E, and capability/agent-surface testing with pracht eval", }, { href: "/docs/recipes/fullstack-cloudflare", diff --git a/examples/docs/src/shells/docs.tsx b/examples/docs/src/shells/docs.tsx index 60113bed..a1c971f2 100644 --- a/examples/docs/src/shells/docs.tsx +++ b/examples/docs/src/shells/docs.tsx @@ -25,6 +25,8 @@ import { IconSparkles, IconPresentationAnalytics, IconActivity, + IconShieldCheck, + IconWorldBolt, } from "@tabler/icons-preact"; import "../styles/global.css"; @@ -64,9 +66,17 @@ const NAV = [ links: [ { href: "/docs/prefetching", Icon: IconBolt, title: "Prefetching" }, { href: "/docs/performance", Icon: IconGauge, title: "Performance" }, + ], + }, + { + label: "Agents", + links: [ + { href: "/docs/agents", Icon: IconWorldBolt, title: "The Agentic Web" }, { href: "/docs/llms", Icon: IconSparkles, title: "LLM Content" }, { href: "/docs/agent-workflow", Icon: IconRobot, title: "Agent Workflow" }, { href: "/docs/agent-skills", Icon: IconSparkles, title: "Agent Skills" }, + { href: "/docs/capabilities", Icon: IconRobot, title: "Capabilities" }, + { href: "/docs/agent-trust", Icon: IconShieldCheck, title: "Agent Trust" }, ], }, { diff --git a/examples/pages-router/src/pages/about.tsx b/examples/pages-router/src/pages/about.tsx index 57e4ef90..d969e2db 100644 --- a/examples/pages-router/src/pages/about.tsx +++ b/examples/pages-router/src/pages/about.tsx @@ -2,6 +2,13 @@ import { useLocation } from "@pracht/core"; export const RENDER_MODE = "ssg"; +// Served when a client requests `Accept: text/markdown` (Markdown-for-Agents); +// llms.txt flags this route as markdown-capable. +export const markdown = `# About + +A static page rendered with SSG via the pages router. +`; + export function Component() { const { pathname, search } = useLocation(); diff --git a/examples/pages-router/vite.config.ts b/examples/pages-router/vite.config.ts index 9f8b6b84..145841c8 100644 --- a/examples/pages-router/vite.config.ts +++ b/examples/pages-router/vite.config.ts @@ -7,5 +7,11 @@ async function resolveAdapter() { } export default defineConfig(async () => ({ - plugins: [pracht({ pagesDir: "/src/pages", adapter: await resolveAdapter() })], + plugins: [ + pracht({ + pagesDir: "/src/pages", + adapter: await resolveAdapter(), + llmsTxt: { title: "Pracht Pages Example" }, + }), + ], })); diff --git a/package.json b/package.json index d095bf6e..a145d66c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "type": "module", "scripts": { - "build": "pnpm -r --filter @pracht/core --filter @pracht/vite-plugin --filter @pracht/preact-ssr-precompile --filter @pracht/adapter-node --filter @pracht/adapter-cloudflare --filter @pracht/adapter-vercel --filter @pracht/cli --filter create-pracht run build", + "build": "pnpm -r --filter @pracht/core --filter @pracht/capabilities --filter @pracht/vite-plugin --filter @pracht/preact-ssr-precompile --filter @pracht/adapter-node --filter @pracht/adapter-cloudflare --filter @pracht/adapter-vercel --filter @pracht/cli --filter create-pracht run build", "check": "pnpm build && pnpm typecheck && pnpm test", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/packages/capabilities/LICENSE b/packages/capabilities/LICENSE new file mode 100644 index 00000000..53d850cc --- /dev/null +++ b/packages/capabilities/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jovi De Croock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/capabilities/README.md b/packages/capabilities/README.md new file mode 100644 index 00000000..0e361208 --- /dev/null +++ b/packages/capabilities/README.md @@ -0,0 +1,58 @@ +# @pracht/capabilities + +Typed, protocol-neutral application capabilities for [Pracht](https://github.com/JoviDeCroock/pracht). + +Define an application operation once — JSON Schema input/output, an effect +class (`read` / `write` / `destructive`), named middleware, and a server-only +`run()` — and let Pracht project it to direct server invocation, a generated +HTTP endpoint, and a WebMCP page tool for in-browser agents. + +```ts +import { defineCapability } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Search notes", + description: "Find notes whose title or body matches the query.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array" } }, + required: ["notes"], + }, + effect: "read", + expose: { http: true, webmcp: true }, + async run({ input, context, request, signal }) { + return { notes: await searchNotes(input.query, input.limit) }; + }, +}); +``` + +Register capabilities in the app manifest: + +```ts +export const app = defineApp({ + capabilities: { + "notes.search": () => import("./capabilities/notes-search.ts"), + }, + // ... +}); +``` + +Capabilities are private by default; `expose.http` serves them at +`POST /api/capabilities/` with a typed +`{ ok, data | error }` envelope, and `expose.webmcp` registers them as WebMCP +page tools that dispatch through the HTTP projection so all enforcement stays +server-side. Validation uses a dependency-free JSON Schema subset — no ajv or +zod in your bundles. + +See [docs/CAPABILITIES.md](https://github.com/JoviDeCroock/pracht/blob/main/docs/CAPABILITIES.md) +for the full guide, including the supported schema subset and security +defaults. diff --git a/packages/capabilities/package.json b/packages/capabilities/package.json new file mode 100644 index 00000000..85a5e4fa --- /dev/null +++ b/packages/capabilities/package.json @@ -0,0 +1,45 @@ +{ + "name": "@pracht/capabilities", + "version": "0.0.0", + "description": "Typed, protocol-neutral application capabilities for Pracht: defineCapability with JSON Schema validation, effect classes, and explicit exposure.", + "keywords": [ + "pracht", + "preact", + "capabilities", + "json-schema", + "agents", + "webmcp", + "mcp" + ], + "license": "MIT", + "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/capabilities", + "bugs": { + "url": "https://github.com/JoviDeCroock/pracht/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/JoviDeCroock/pracht", + "directory": "packages/capabilities" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./static": { + "types": "./dist/static.d.mts", + "default": "./dist/static.mjs" + } + }, + "publishConfig": { + "provenance": true + }, + "scripts": { + "build": "tsdown", + "prepublishOnly": "npm run build" + } +} diff --git a/packages/capabilities/src/capability.ts b/packages/capabilities/src/capability.ts new file mode 100644 index 00000000..cf7a37f0 --- /dev/null +++ b/packages/capabilities/src/capability.ts @@ -0,0 +1,284 @@ +import { + isValidCapabilityHttpPath, + type CapabilityErrorCode, + type PrachtAgentIdentity, +} from "./protocol.ts"; +import { + applySchemaDefaults, + collectInvalidSchemaKeywordValues, + collectUnsupportedSchemaKeywords, + validateAgainstSchema, + type JsonSchema, + type CapabilityIssue, +} from "./schema.ts"; + +/** + * Side-effect classification. Every capability must declare one; the + * framework's exposure policy is driven by it. `destructive` capabilities + * may only be exposed over HTTP, where every dispatch is gated by the + * server-verified prepare/commit confirmation flow (see docs/AGENT_TRUST.md); + * agent projections (`webmcp`/`mcp`) stay disallowed for them in v1. + */ +export type CapabilityEffect = "read" | "write" | "destructive"; + +/** + * Web Bot Auth policy for the capability's HTTP endpoint: + * - `"observe"` — serve everyone, surface the verified identity on context; + * - `"require"` — reject unsigned/unverified requests with a 401 envelope. + * Unset inherits the app-wide default from `defineApp({ agents })`. + */ +export type CapabilityAgentPolicy = "observe" | "require"; + +export interface CapabilityHttpExposure { + method: "POST"; + /** Custom dispatch path. Defaults to `/api/capabilities/`. */ + path?: string; +} + +export interface CapabilityExposeConfig { + /** Serve the capability over HTTP. `true` uses `POST` at the default path. */ + http?: true | { method?: "POST"; path?: string }; + /** Advertise the capability to the remote MCP projection (not built yet — recorded in the graph only). */ + mcp?: boolean; + /** Register the capability as a WebMCP page tool. Requires `http` — calls dispatch through the HTTP projection. */ + webmcp?: boolean; +} + +/** Normalized exposure — what the framework and graph consume. */ +export interface CapabilityExposure { + http: CapabilityHttpExposure | null; + mcp: boolean; + webmcp: boolean; +} + +/** + * The request context a capability handler receives by default: the verified + * agent identity the framework surfaces on every request, plus whatever app + * middleware attached. Narrow the open part with your own context type via + * the third `defineCapability` generic. + */ +export interface CapabilityContext { + /** Verified agent identity (Web Bot Auth); `null` when unsigned/unverified, absent when the app does not configure agents. */ + agent?: PrachtAgentIdentity | null; + [key: string]: unknown; +} + +export interface CapabilityRunArgs { + input: TInput; + context: TContext; + request: Request; + signal: AbortSignal; +} + +export interface CapabilityDefinition< + TInput = unknown, + TOutput = unknown, + TContext = CapabilityContext, +> { + title: string; + description: string; + /** JSON Schema (supported subset) for the capability input. */ + input: JsonSchema; + /** JSON Schema (supported subset) for the capability output. */ + output: JsonSchema; + effect: CapabilityEffect; + /** Named middleware from the app manifest, run before the handler. */ + middleware?: string[]; + /** Explicit exposure. A capability without `expose` is only callable server-side. */ + expose?: CapabilityExposeConfig; + /** Per-capability Web Bot Auth policy override for the HTTP endpoint. */ + agentPolicy?: CapabilityAgentPolicy; + run: (args: CapabilityRunArgs) => TOutput | Promise; +} + +export type CapabilityValidationResult = + | { ok: true; value: T } + | { ok: false; issues: CapabilityIssue[] }; + +/** + * The object `defineCapability()` returns. The validation methods are + * attached here so the framework runtime can execute capabilities through a + * structural contract without depending on this package. + */ +export interface Capability { + kind: "capability"; + title: string; + description: string; + input: JsonSchema; + output: JsonSchema; + effect: CapabilityEffect; + middleware: string[]; + expose: CapabilityExposure | null; + agentPolicy?: CapabilityAgentPolicy; + run: (args: CapabilityRunArgs) => TOutput | Promise; + /** Apply input defaults and validate. Returns the defaulted value on success. */ + validateInput: (value: unknown) => CapabilityValidationResult; + validateOutput: (value: unknown) => CapabilityValidationResult; +} + +/** Result/error envelope shared by HTTP, WebMCP, and direct server invocation. */ +export type CapabilityEnvelope = + | { ok: true; data: T } + | { ok: false; error: CapabilityErrorPayload }; + +export interface CapabilityErrorPayload { + code: CapabilityErrorCode; + message: string; + issues?: CapabilityIssue[]; + /** Present on `confirmation_required` errors: pass it back via the CONFIRMATION_HEADER. */ + confirmationToken?: string; + /** Unix seconds when `confirmationToken` expires. */ + expiresAt?: number; +} + +export const DESTRUCTIVE_EXPOSURE_ERROR = + "destructive capabilities cannot be exposed to agent projections (webmcp/mcp) yet — " + + "only expose.http, where the prepare/commit confirmation flow gates every call"; + +/** + * Define a protocol-neutral application capability. + * + * Fails fast (throws) on invalid definitions instead of deferring problems to + * request time: missing contract fields, schemas outside the supported JSON + * Schema subset, `webmcp` exposure without an HTTP projection to dispatch + * through, and `webmcp`/`mcp` exposure of a `destructive` capability + * (destructive + `expose.http` is allowed — the runtime's server-verified + * prepare/commit confirmation flow gates every dispatch). + */ +export function defineCapability( + definition: CapabilityDefinition, +): Capability { + assertDefinition(definition); + + const expose = normalizeExposure(definition.expose); + + if (definition.effect === "destructive" && (expose?.webmcp || expose?.mcp)) { + throw new Error(`defineCapability("${definition.title}"): ${DESTRUCTIVE_EXPOSURE_ERROR}.`); + } + if (expose?.webmcp && !expose.http) { + throw new Error( + `defineCapability("${definition.title}"): expose.webmcp requires expose.http — ` + + "WebMCP page tools dispatch through the HTTP projection so all enforcement stays server-side.", + ); + } + + return { + kind: "capability", + title: definition.title, + description: definition.description, + input: definition.input, + output: definition.output, + effect: definition.effect, + middleware: definition.middleware ?? [], + expose, + agentPolicy: definition.agentPolicy, + run: definition.run, + validateInput(value: unknown): CapabilityValidationResult { + const withDefaults = applySchemaDefaults(definition.input, value === undefined ? {} : value); + const issues = validateAgainstSchema(definition.input, withDefaults); + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, value: withDefaults as TInput }; + }, + validateOutput(value: unknown): CapabilityValidationResult { + const issues = validateAgainstSchema(definition.output, value); + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, value: value as TOutput }; + }, + }; +} + +function assertDefinition(definition: CapabilityDefinition): void { + const label = typeof definition?.title === "string" ? definition.title : ""; + + if (!definition || typeof definition !== "object") { + throw new Error("defineCapability expects a definition object."); + } + for (const field of ["title", "description"] as const) { + if (typeof definition[field] !== "string" || definition[field].trim() === "") { + throw new Error(`defineCapability("${label}"): "${field}" must be a non-empty string.`); + } + } + if ( + definition.effect !== "read" && + definition.effect !== "write" && + definition.effect !== "destructive" + ) { + throw new Error( + `defineCapability("${label}"): "effect" must be "read", "write", or "destructive".`, + ); + } + if (typeof definition.run !== "function") { + throw new Error(`defineCapability("${label}"): "run" must be a function.`); + } + + for (const field of ["input", "output"] as const) { + const schema = definition[field]; + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { + throw new Error(`defineCapability("${label}"): "${field}" must be a JSON Schema object.`); + } + const unsupported = collectUnsupportedSchemaKeywords(schema); + const invalid = collectInvalidSchemaKeywordValues(schema); + if (unsupported.length > 0) { + throw new Error( + `defineCapability("${label}"): "${field}" schema uses unsupported JSON Schema keywords: ` + + `${unsupported.join(", ")}. Supported keywords: type (object/array/string/number/` + + "integer/boolean/null), properties, required, additionalProperties, items, enum, " + + "const, minimum, maximum, minLength, maxLength, default, title, description.", + ); + } + if (invalid.length > 0) { + throw new Error( + `defineCapability("${label}"): "${field}" schema has invalid JSON Schema values: ` + + `${invalid.join(", ")}.`, + ); + } + } + + if ( + definition.middleware !== undefined && + (!Array.isArray(definition.middleware) || + definition.middleware.some((name) => typeof name !== "string")) + ) { + throw new Error(`defineCapability("${label}"): "middleware" must be an array of names.`); + } + + if ( + definition.agentPolicy !== undefined && + definition.agentPolicy !== "observe" && + definition.agentPolicy !== "require" + ) { + throw new Error(`defineCapability("${label}"): "agentPolicy" must be "observe" or "require".`); + } +} + +function normalizeExposure(expose: CapabilityExposeConfig | undefined): CapabilityExposure | null { + if (!expose) return null; + + let http: CapabilityHttpExposure | null = null; + if (expose.http === true) { + http = { method: "POST" }; + } else if (expose.http && typeof expose.http === "object") { + if (expose.http.method !== undefined && expose.http.method !== "POST") { + throw new Error('Capability HTTP exposure only supports method: "POST" for now.'); + } + if (expose.http.path !== undefined) { + if (!isValidCapabilityHttpPath(expose.http.path)) { + throw new Error( + 'Capability HTTP exposure "path" must be an exact same-origin pathname starting with "/".', + ); + } + http = { method: "POST", path: expose.http.path }; + } else { + http = { method: "POST" }; + } + } + + const normalized: CapabilityExposure = { + http, + mcp: expose.mcp === true, + webmcp: expose.webmcp === true, + }; + + if (!normalized.http && !normalized.mcp && !normalized.webmcp) return null; + return normalized; +} diff --git a/packages/capabilities/src/form.ts b/packages/capabilities/src/form.ts new file mode 100644 index 00000000..5eabe0e1 --- /dev/null +++ b/packages/capabilities/src/form.ts @@ -0,0 +1,78 @@ +/** + * Coerce HTML form fields into the shapes a capability input schema expects. + * + * Progressive-enhancement `` submissions arrive as + * `application/x-www-form-urlencoded` strings; the framework maps them onto + * the input schema before validation: numbers are parsed, checkbox values + * become booleans, and repeated fields become arrays when the schema says + * array. Values that do not parse pass through unchanged so schema validation + * produces its usual, precise issue paths instead of a coercion error. + */ +export function coerceFormInput( + schema: unknown, + entries: Iterable<[string, unknown]>, +): Record { + const properties = + isPlainObject(schema) && isPlainObject(schema.properties) ? schema.properties : {}; + + const grouped = new Map(); + for (const [name, raw] of entries) { + const bucket = grouped.get(name) ?? []; + bucket.push(raw); + grouped.set(name, bucket); + } + + const result: Record = {}; + for (const [name, values] of grouped) { + // Own-property lookup so a field named "__proto__" or "constructor" cannot + // pick up an inherited member as its schema. + const declared = Object.hasOwn(properties, name) ? properties[name] : undefined; + const propertySchema = isPlainObject(declared) ? declared : null; + + let coerced: unknown; + if (propertySchema?.type === "array") { + const itemType = isPlainObject(propertySchema.items) ? propertySchema.items.type : undefined; + coerced = values.map((value) => coerceScalar(itemType, value)); + } else { + // Repeated fields collapse to the last value, like URLSearchParams.get + // from the end — a non-array schema cannot accept more than one anyway. + coerced = coerceScalar(propertySchema?.type, values[values.length - 1]); + } + + // defineProperty rather than assignment: `result.__proto__ = value` hits + // the prototype setter, so the field would silently vanish instead of + // being rejected by the schema's `additionalProperties: false`. + Object.defineProperty(result, name, { + configurable: true, + enumerable: true, + value: coerced, + writable: true, + }); + } + return result; +} + +function coerceScalar(type: unknown, value: unknown): unknown { + if (typeof value !== "string") return value; + switch (type) { + case "number": + case "integer": { + if (value.trim() === "") return value; + const parsed = Number(value); + return Number.isNaN(parsed) ? value : parsed; + } + case "boolean": + // Checkboxes post "on" when checked and nothing when unchecked. + if (value === "true" || value === "on") return true; + if (value === "false") return false; + return value; + case "null": + return value === "" || value === "null" ? null : value; + default: + return value; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/capabilities/src/index.ts b/packages/capabilities/src/index.ts new file mode 100644 index 00000000..662238cc --- /dev/null +++ b/packages/capabilities/src/index.ts @@ -0,0 +1,40 @@ +export { defineCapability, DESTRUCTIVE_EXPOSURE_ERROR } from "./capability.ts"; +export type { + Capability, + CapabilityAgentPolicy, + CapabilityContext, + CapabilityDefinition, + CapabilityEffect, + CapabilityEnvelope, + CapabilityErrorPayload, + CapabilityExposeConfig, + CapabilityExposure, + CapabilityHttpExposure, + CapabilityRunArgs, + CapabilityValidationResult, +} from "./capability.ts"; +export { + CAPABILITY_EFFECT_HEADER, + CAPABILITY_ERROR_CODES, + CAPABILITY_FORM_REDIRECT_HEADER, + CAPABILITY_FORM_REQUEST_HEADER, + CAPABILITY_HTTP_PREFIX, + CAPABILITY_SETTLED_EVENT, + CAPABILITY_TRANSPORT_HEADER, + capabilityHttpPath, + CONFIRMATION_HEADER, + CONFIRMATION_SECRET_ENV, + isValidCapabilityHttpPath, + normalizeCapabilityHttpPath, +} from "./protocol.ts"; +export type { CapabilityErrorCode, PrachtAgentIdentity } from "./protocol.ts"; +export { + applySchemaDefaults, + collectInvalidSchemaKeywordValues, + collectUnsupportedSchemaKeywords, + validateAgainstSchema, +} from "./schema.ts"; +export type { CapabilityIssue, JsonSchema } from "./schema.ts"; +export { coerceFormInput } from "./form.ts"; +export { schemaToTypeText } from "./schema-type-text.ts"; +export type { SchemaTypePosition } from "./schema-type-text.ts"; diff --git a/packages/capabilities/src/protocol.ts b/packages/capabilities/src/protocol.ts new file mode 100644 index 00000000..bc696917 --- /dev/null +++ b/packages/capabilities/src/protocol.ts @@ -0,0 +1,129 @@ +/** + * The capability wire contract — the single home for every name the + * projections share: the HTTP path formula, the confirmation header, and the + * envelope error codes. The framework runtime, the Vite plugin's generated + * client modules, and the CLI (eval runner, verify, typegen) all import from + * here, so the protocol cannot drift between packages. + */ + +export const CAPABILITY_HTTP_PREFIX = "/api/capabilities/"; + +/** Default HTTP path for a capability name: dots become slashes. */ +export function capabilityHttpPath(name: string): string { + return `${CAPABILITY_HTTP_PREFIX}${name.split(".").join("/")}`; +} + +/** Normalize a dispatch path for matching: strip a single trailing slash. */ +export function normalizeCapabilityHttpPath(path: string): string { + return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; +} + +/** + * Whether a custom capability endpoint is an exact same-origin pathname. + * + * Parsing against a fixed origin catches protocol-relative paths, backslashes, + * ASCII control characters, dot-segment normalization, queries, and fragments. + * Requiring the parsed pathname to equal the source keeps generated browser + * fetches on the application origin. + */ +export function isValidCapabilityHttpPath(path: unknown): path is string { + if (typeof path !== "string" || !path.startsWith("/")) return false; + try { + const parsed = new URL(path, "https://pracht.invalid"); + return ( + parsed.origin === "https://pracht.invalid" && + parsed.pathname === path && + parsed.search === "" && + parsed.hash === "" + ); + } catch { + return false; + } +} + +/** + * Header that carries the prepare/commit confirmation token when committing a + * destructive capability call (see docs/AGENT_TRUST.md). + */ +export const CONFIRMATION_HEADER = "x-pracht-confirm"; + +/** Environment variable holding the confirmation-token HMAC secret. */ +export const CONFIRMATION_SECRET_ENV = "PRACHT_CONFIRMATION_SECRET"; + +/** + * Every error code a capability envelope can carry. The first group is + * produced by the server dispatch pipeline; `network_error` and + * `invalid_response` are produced client-side by the generated + * `callCapability()` helper when the endpoint cannot be reached or answers + * with something other than the envelope. + */ +export const CAPABILITY_ERROR_CODES = [ + "invalid_input", + "invalid_output", + "invalid_json", + "internal_error", + "method_not_allowed", + "agent_required", + "confirmation_required", + "confirmation_unavailable", + "confirmation_invalid", + "unknown_capability", + "unauthorized", + "forbidden", + "middleware_rejected", + "redirect", + "cross_origin_blocked", + "network_error", + "invalid_response", +] as const; + +export type CapabilityErrorCode = (typeof CAPABILITY_ERROR_CODES)[number]; + +/** + * Optional transport marker the generated WebMCP shim sends with its + * dispatches so audit events can distinguish in-browser agent traffic + * (cookie-authenticated) from remote HTTP callers. Informational only — like + * any client-sent header it is not a trust signal. + */ +export const CAPABILITY_TRANSPORT_HEADER = "x-pracht-transport"; + +/** + * Response header carrying the matched capability's effect class. Enhanced + * `` submissions read it so successful `read` operations do + * not invalidate route data while mutations still do. + */ +export const CAPABILITY_EFFECT_HEADER = "x-pracht-capability-effect"; + +/** + * Marker sent by enhanced `` submissions. Pracht API and + * capability dispatch turn redirect responses into a readable redirect + * header so the browser can navigate without following an external target as + * a CORS fetch. + */ +export const CAPABILITY_FORM_REQUEST_HEADER = "x-pracht-capability-form"; + +/** Redirect target returned for an enhanced capability-form submission. */ +export const CAPABILITY_FORM_REDIRECT_HEADER = "x-pracht-capability-redirect"; + +/** + * Window event dispatched after a browser-side capability call settles — + * by the generated `callCapability()` helper and by ``. + * The framework's route runtime listens and revalidates the active route's + * data after successful non-`read` calls, so mutations made through the + * agent surface and the human UI keep the page consistent the same way. + * `detail`: `{ name, effect, ok, revalidate }` (`effect`/`revalidate` may be + * absent when an older or non-Pracht dispatcher doesn't know them). + */ +export const CAPABILITY_SETTLED_EVENT = "pracht:capability-settled"; + +/** + * Verified agent identity, surfaced as `context.agent` when the app + * configures Web Bot Auth (`defineApp({ agents: { webBotAuth } })`). + */ +export interface PrachtAgentIdentity { + verified: true; + /** Host of the agent's Signature-Agent directory URL (or the static key's `agent` label). */ + agentDomain: string | null; + /** The `keyid` signature parameter (base64url JWK thumbprint). */ + keyId: string; +} diff --git a/packages/capabilities/src/schema-type-text.ts b/packages/capabilities/src/schema-type-text.ts new file mode 100644 index 00000000..f960e250 --- /dev/null +++ b/packages/capabilities/src/schema-type-text.ts @@ -0,0 +1,98 @@ +/** + * JSON Schema (supported subset) → TypeScript type text, used by + * `pracht typegen` to emit capability input/output types. It lives next to + * the schema subset definition (see schema.ts) so the two evolve in lockstep: + * a keyword added to the subset must be handled here or it degrades to + * `unknown` instead of guessing. + * + * Position matters for optionality: + * - `"input"` — a property is optional for the caller when it is not + * `required` or when it declares a `default` (defaults are applied before + * validation, so the caller may always omit it); + * - `"output"` — a property is optional exactly when it is not `required`. + */ + +export type SchemaTypePosition = "input" | "output"; + +export function schemaToTypeText(schema: unknown, position: SchemaTypePosition): string { + if (!isPlainObject(schema)) return "unknown"; + + if ("const" in schema) { + return jsonToLiteralType(schema.const); + } + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + return schema.enum.map(jsonToLiteralType).join(" | "); + } + + switch (schema.type) { + case "string": + return "string"; + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "array": + return `Array<${schemaToTypeText(schema.items, position)}>`; + case "object": + return objectTypeText(schema, position); + default: + return "unknown"; + } +} + +function objectTypeText(schema: Record, position: SchemaTypePosition): string { + const properties = isPlainObject(schema.properties) ? schema.properties : null; + const additional = schema.additionalProperties; + + if (!properties || Object.keys(properties).length === 0) { + if (additional === false) return "Record"; + if (isPlainObject(additional)) { + return `Record`; + } + return "Record"; + } + + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : [], + ); + + const members = Object.entries(properties).map(([name, propertySchema]) => { + const hasDefault = isPlainObject(propertySchema) && "default" in propertySchema; + const optional = position === "input" ? !required.has(name) || hasDefault : !required.has(name); + return `${JSON.stringify(name)}${optional ? "?" : ""}: ${schemaToTypeText(propertySchema, position)};`; + }); + + if (additional !== false) { + // Open objects accept extra members; type them so access stays checked. + members.push("[key: string]: unknown;"); + } + + return `{ ${members.join(" ")} }`; +} + +/** Render a JSON value as a TypeScript literal type (for `const`/`enum`). */ +function jsonToLiteralType(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(jsonToLiteralType).join(", ")}]`; + } + if (isPlainObject(value)) { + const members = Object.entries(value).map( + ([name, member]) => `${JSON.stringify(name)}: ${jsonToLiteralType(member)};`, + ); + return `{ ${members.join(" ")} }`; + } + return "unknown"; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/capabilities/src/schema.ts b/packages/capabilities/src/schema.ts new file mode 100644 index 00000000..1b723bc2 --- /dev/null +++ b/packages/capabilities/src/schema.ts @@ -0,0 +1,443 @@ +/** + * Dependency-free JSON Schema subset validator. + * + * Capabilities store plain JSON Schema so the graph stays serializable and + * the same schema can be projected to agent surfaces (WebMCP, MCP) without a + * runtime schema library in application bundles. Only a deliberate subset is + * supported; schemas using anything else are rejected at definition time so + * a keyword the validator would silently ignore can never widen what an + * exposed capability accepts. + * + * Supported keywords: + * type (object/array/string/number/integer/boolean/null), properties, + * required, additionalProperties, items, enum, const, minimum, maximum, + * minLength, maxLength, default (applied to input), plus the pure + * annotations title and description. + */ + +export type JsonSchema = Record; + +export interface CapabilityIssue { + /** JSON-pointer-ish path into the validated value, e.g. "/limit". Empty for the root. */ + path: string; + message: string; +} + +const SUPPORTED_KEYWORDS = new Set([ + "type", + "properties", + "required", + "additionalProperties", + "items", + "enum", + "const", + "minimum", + "maximum", + "minLength", + "maxLength", + "default", + // Pure annotations — never affect validation but are useful for agents. + "title", + "description", +]); + +const SUPPORTED_TYPES = new Set([ + "object", + "array", + "string", + "number", + "integer", + "boolean", + "null", +]); + +/** + * Walk a schema and collect every keyword outside the supported subset, + * prefixed with its schema path (e.g. `/properties/query/pattern`). Used by + * `defineCapability()` to fail fast and by `pracht verify` messaging. + */ +export function collectUnsupportedSchemaKeywords(schema: unknown, path = ""): string[] { + if (!isPlainObject(schema)) return []; + + const unsupported: string[] = []; + for (const key of Object.keys(schema)) { + if (!SUPPORTED_KEYWORDS.has(key)) { + unsupported.push(`${path}/${key}`); + } + } + + if (typeof schema.type === "string" && !SUPPORTED_TYPES.has(schema.type)) { + unsupported.push(`${path}/type:${String(schema.type)}`); + } + if (Array.isArray(schema.type)) { + unsupported.push(`${path}/type:`); + } + + if (isPlainObject(schema.properties)) { + for (const [name, propertySchema] of Object.entries(schema.properties)) { + unsupported.push( + ...collectUnsupportedSchemaKeywords(propertySchema, `${path}/properties/${name}`), + ); + } + } + if (isPlainObject(schema.items)) { + unsupported.push(...collectUnsupportedSchemaKeywords(schema.items, `${path}/items`)); + } + if (Array.isArray(schema.items)) { + unsupported.push(`${path}/items:`); + } + if (isPlainObject(schema.additionalProperties)) { + unsupported.push( + ...collectUnsupportedSchemaKeywords( + schema.additionalProperties, + `${path}/additionalProperties`, + ), + ); + } + + return unsupported; +} + +/** Collect malformed values for keywords in the supported schema subset. */ +export function collectInvalidSchemaKeywordValues(schema: unknown, path = ""): string[] { + if (!isPlainObject(schema)) return [`${path || "/"}:`]; + + const invalid: string[] = []; + if ("type" in schema && (typeof schema.type !== "string" || !SUPPORTED_TYPES.has(schema.type))) { + invalid.push(`${path}/type:`); + } + if ("properties" in schema && !isPlainObject(schema.properties)) { + invalid.push(`${path}/properties:`); + } + if ( + "required" in schema && + (!Array.isArray(schema.required) || schema.required.some((name) => typeof name !== "string")) + ) { + invalid.push(`${path}/required:`); + } + if ( + "additionalProperties" in schema && + typeof schema.additionalProperties !== "boolean" && + !isPlainObject(schema.additionalProperties) + ) { + invalid.push(`${path}/additionalProperties:`); + } + if ("items" in schema && !isPlainObject(schema.items)) { + invalid.push(`${path}/items:`); + } + if ("enum" in schema && (!Array.isArray(schema.enum) || schema.enum.length === 0)) { + invalid.push(`${path}/enum:`); + } else if (Array.isArray(schema.enum)) { + for (const [index, value] of schema.enum.entries()) { + if (!isJsonValue(value)) { + invalid.push(`${path}/enum/${index}:`); + } + } + } + for (const keyword of ["const", "default"] as const) { + if (keyword in schema && !isJsonValue(schema[keyword])) { + invalid.push(`${path}/${keyword}:`); + } + } + for (const keyword of ["minimum", "maximum"] as const) { + if ( + keyword in schema && + (typeof schema[keyword] !== "number" || !Number.isFinite(schema[keyword])) + ) { + invalid.push(`${path}/${keyword}:`); + } + } + for (const keyword of ["minLength", "maxLength"] as const) { + if ( + keyword in schema && + (typeof schema[keyword] !== "number" || + !Number.isInteger(schema[keyword]) || + schema[keyword] < 0) + ) { + invalid.push(`${path}/${keyword}:`); + } + } + for (const keyword of ["title", "description"] as const) { + if (keyword in schema && typeof schema[keyword] !== "string") { + invalid.push(`${path}/${keyword}:`); + } + } + + if (isPlainObject(schema.properties)) { + for (const [name, propertySchema] of Object.entries(schema.properties)) { + invalid.push( + ...collectInvalidSchemaKeywordValues(propertySchema, `${path}/properties/${name}`), + ); + } + } + if (isPlainObject(schema.items)) { + invalid.push(...collectInvalidSchemaKeywordValues(schema.items, `${path}/items`)); + } + if (isPlainObject(schema.additionalProperties)) { + invalid.push( + ...collectInvalidSchemaKeywordValues( + schema.additionalProperties, + `${path}/additionalProperties`, + ), + ); + } + + return invalid; +} + +/** + * Return a copy of `value` with schema `default`s filled in for missing + * object properties, recursively. The input value is never mutated. + */ +export function applySchemaDefaults(schema: unknown, value: unknown): unknown { + if (!isPlainObject(schema)) return value; + + if (isPlainObject(value) && isPlainObject(schema.properties)) { + const result: Record = { ...value }; + for (const [name, propertySchema] of Object.entries(schema.properties)) { + if (!Object.hasOwn(result, name)) { + if (isPlainObject(propertySchema) && "default" in propertySchema) { + result[name] = cloneJson(propertySchema.default); + } + continue; + } + result[name] = applySchemaDefaults(propertySchema, result[name]); + } + return result; + } + + if (Array.isArray(value) && isPlainObject(schema.items)) { + return value.map((item) => applySchemaDefaults(schema.items, item)); + } + + return value; +} + +/** + * Validate `value` against the schema subset. Returns an empty array when the + * value conforms. Every issue carries a path scoped to the offending value so + * callers (and agents) can pinpoint what to fix. + */ +export function validateAgainstSchema( + schema: unknown, + value: unknown, + path = "", +): CapabilityIssue[] { + const nonJsonIssue = findNonJsonIssue(value, path); + if (nonJsonIssue) return [nonJsonIssue]; + return validateJsonAgainstSchema(schema, value, path); +} + +function validateJsonAgainstSchema( + schema: unknown, + value: unknown, + path: string, +): CapabilityIssue[] { + if (!isPlainObject(schema)) return []; + + const issues: CapabilityIssue[] = []; + + if ("const" in schema && !jsonEquals(value, schema.const)) { + issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` }); + return issues; + } + + if ( + Array.isArray(schema.enum) && + !schema.enum.some((candidate) => jsonEquals(value, candidate)) + ) { + issues.push({ + path, + message: `must be one of ${schema.enum.map((candidate) => JSON.stringify(candidate)).join(", ")}`, + }); + return issues; + } + + const type = typeof schema.type === "string" ? schema.type : undefined; + if (type && !matchesType(type, value)) { + issues.push({ path, message: `must be of type ${type}, got ${describeValue(value)}` }); + return issues; + } + + if (typeof value === "string") { + // JSON Schema measures string length in Unicode code points, while + // JavaScript's String#length counts UTF-16 code units. Count code points so + // astral characters such as emoji contribute one character, not two. + const length = Array.from(value).length; + if (typeof schema.minLength === "number" && length < schema.minLength) { + issues.push({ path, message: `must be at least ${schema.minLength} character(s) long` }); + } + if (typeof schema.maxLength === "number" && length > schema.maxLength) { + issues.push({ path, message: `must be at most ${schema.maxLength} character(s) long` }); + } + } + + if (typeof value === "number") { + if (typeof schema.minimum === "number" && value < schema.minimum) { + issues.push({ path, message: `must be >= ${schema.minimum}` }); + } + if (typeof schema.maximum === "number" && value > schema.maximum) { + issues.push({ path, message: `must be <= ${schema.maximum}` }); + } + } + + if (isPlainObject(value)) { + const properties = isPlainObject(schema.properties) ? schema.properties : {}; + + if (Array.isArray(schema.required)) { + for (const name of schema.required) { + if (typeof name === "string" && !Object.hasOwn(value, name)) { + issues.push({ path: `${path}/${name}`, message: "is required" }); + } + } + } + + for (const [name, propertyValue] of Object.entries(value)) { + if (Object.hasOwn(properties, name)) { + const propertySchema = properties[name]; + issues.push(...validateJsonAgainstSchema(propertySchema, propertyValue, `${path}/${name}`)); + continue; + } + + if (schema.additionalProperties === false) { + issues.push({ path: `${path}/${name}`, message: "is not an allowed property" }); + } else if (isPlainObject(schema.additionalProperties)) { + issues.push( + ...validateJsonAgainstSchema( + schema.additionalProperties, + propertyValue, + `${path}/${name}`, + ), + ); + } + } + } + + if (Array.isArray(value) && isPlainObject(schema.items)) { + for (let index = 0; index < value.length; index += 1) { + issues.push(...validateJsonAgainstSchema(schema.items, value[index], `${path}/${index}`)); + } + } + + return issues; +} + +/** + * JSON Schema describes JSON data, so reject JavaScript-only values even when + * the schema is unconstrained or permits additional properties. This matters + * for direct invocation and multipart forms, which can otherwise introduce + * values such as `File`/`Blob` that JSON requests can never represent. + */ +function findNonJsonIssue( + value: unknown, + path: string, + ancestors = new Set(), +): CapabilityIssue | null { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + return null; + } + + if (typeof value !== "object") { + return { path, message: `must be JSON-serializable, got ${typeof value}` }; + } + if (!Array.isArray(value) && !isPlainObject(value)) { + return { path, message: "must be JSON-serializable, got object" }; + } + if (ancestors.has(value)) { + return { path, message: "must be JSON-serializable, got a circular reference" }; + } + + ancestors.add(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + ancestors.delete(value); + return { + path: `${path}/${index}`, + message: "must be JSON-serializable, got a sparse array slot", + }; + } + const issue = findNonJsonIssue(value[index], `${path}/${index}`, ancestors); + if (issue) { + ancestors.delete(value); + return issue; + } + } + } else { + for (const [key, entry] of Object.entries(value)) { + const issue = findNonJsonIssue(entry, `${path}/${key}`, ancestors); + if (issue) { + ancestors.delete(value); + return issue; + } + } + } + ancestors.delete(value); + return null; +} + +function isJsonValue(value: unknown): boolean { + return findNonJsonIssue(value, "") === null; +} + +function matchesType(type: string, value: unknown): boolean { + switch (type) { + case "object": + return isPlainObject(value); + case "array": + return Array.isArray(value); + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + default: + return false; + } +} + +function describeValue(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function jsonEquals(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== typeof right) return false; + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && left.every((item, index) => jsonEquals(item, right[index])) + ); + } + if (isPlainObject(left) && isPlainObject(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => Object.hasOwn(right, key) && jsonEquals(left[key], right[key])) + ); + } + return false; +} + +function cloneJson(value: T): T { + if (value === null || typeof value !== "object") return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/capabilities/src/static.ts b/packages/capabilities/src/static.ts new file mode 100644 index 00000000..5c61c96b --- /dev/null +++ b/packages/capabilities/src/static.ts @@ -0,0 +1,868 @@ +/** + * Static analysis of capability sources — shared by the Vite plugin (client + * projection codegen) and the CLI (`pracht verify`). Both consumers parse the + * same `defineCapability({ ... })` call sites without executing application + * code, so keeping the parser here guarantees the build and verification can + * never disagree about what is statically analyzable. + * + * Constraint this imposes on capability authors: values the tools need + * (`expose`, `effect`, `input`, string fields) must be inline literals — no + * imported constants or spreads. `evaluateLiteral()` parses the literal text + * as data and returns `undefined` for anything else. + */ + +/** + * Extract the argument object text of the *default-exported* + * `defineCapability({ ... })` call. The runtime resolves a capability module + * by its default export, so analysis must agree: a helper `defineCapability()` + * call earlier in the file must not be mistaken for the exported one. Matches + * the call site (optionally with a type argument), not the import binding. + */ +export function extractDefineCapabilityArgs(source: string): string | null { + const searchable = maskCommentsAndStrings(source); + const parenIndex = findDefaultExportedCallParen(searchable); + if (parenIndex === -1) return null; + const braceStart = searchable.indexOf("{", parenIndex); + if (braceStart === -1) return null; + const braceEnd = findMatchingBrace(source, braceStart, "{", "}"); + if (braceEnd === -1) return null; + return source.slice(braceStart + 1, braceEnd); +} + +const CALL_SITE = /defineCapability\s*(?:<[^(]*?>)?\s*\(/g; + +/** + * Index of the `(` of the default-exported `defineCapability()` call, or -1 + * when the module has no analyzable default-exported call. Handles + * `export default defineCapability(...)`, `export default ` (with or + * without a trailing `;`), and `export { as default }`, resolving the + * identifier to its `const/let/var = defineCapability(...)` declaration. + * A named-only call is deliberately not accepted: the runtime requires the + * capability itself to be the module's default export. + */ +function findDefaultExportedCallParen(searchable: string): number { + const direct = /export\s+default\s+defineCapability\s*(?:<[^(]*?>)?\s*\(/.exec(searchable); + if (direct && direct.index != null) { + return direct.index + direct[0].length - 1; + } + + const localName = defaultExportLocalName(searchable); + if (localName) { + const id = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const decl = new RegExp(`\\b(?:const|let|var)\\s+${id}\\b`, "g"); + // The default export refers to the MODULE-scope binding; a shadowed + // declaration inside a function must not win. Prefer the match at brace + // depth 0. + for (const match of searchable.matchAll(decl)) { + if (match.index != null && braceDepthAt(searchable, match.index) === 0) { + const paren = findDefineCapabilityInitializer(searchable, match.index + match[0].length); + if (paren !== -1) return paren; + } + } + } + + return -1; +} + +/** + * Resolve the first assignment of a variable declaration and accept it only + * when its initializer is immediately `defineCapability(...)`. This avoids + * crossing an ASI boundary into a later declaration while still supporting + * multiline and arrow-function type annotations. + */ +function findDefineCapabilityInitializer(searchable: string, start: number): number { + return findCallInitializer(searchable, start, "defineCapability", CALL_SITE.source); +} + +function findCallInitializer( + searchable: string, + start: number, + callName: string, + callPattern = `${callName}\\s*(?:<[^(]*?>)?\\s*\\(`, +): number { + let depth = 0; + for (let index = start; index < searchable.length; index += 1) { + const char = searchable[index]; + if (char === "(" || char === "[" || char === "{") { + depth += 1; + continue; + } + if (char === ")" || char === "]" || char === "}") { + if (depth === 0) return -1; + depth -= 1; + continue; + } + if (depth !== 0) continue; + if (char === ";") return -1; + if (char === "\n" || char === "\r") { + const next = searchable.slice(skipWhitespace(searchable, index + 1)); + if (/^(?:(?:export|import)\b|(?:const|let|var|function|class)\b)/.test(next)) { + return -1; + } + continue; + } + if ( + char === "=" && + searchable[index + 1] !== ">" && + searchable[index - 1] !== "=" && + searchable[index - 1] !== "!" && + searchable[index - 1] !== "<" && + searchable[index - 1] !== ">" + ) { + const initializerStart = skipWhitespace(searchable, index + 1); + const call = new RegExp(`^${callPattern}`).exec(searchable.slice(initializerStart)); + return call ? initializerStart + call[0].length - 1 : -1; + } + } + return -1; +} + +function skipWhitespace(source: string, start: number): number { + let index = start; + while (index < source.length && /\s/.test(source[index])) index += 1; + return index; +} + +/** + * Brace/paren/bracket nesting depth at `index` in an already comment- and + * string-masked source. Depth 0 means module scope. + */ +function braceDepthAt(searchable: string, index: number): number { + let depth = 0; + for (let cursor = 0; cursor < index; cursor += 1) { + const char = searchable[cursor]; + if (char === "{" || char === "(" || char === "[") depth += 1; + else if (char === "}" || char === ")" || char === "]") depth -= 1; + } + return depth; +} + +/** Local binding name of a module's default export, or null. */ +function defaultExportLocalName(searchable: string): string | null { + const idMatch = /export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)\b/.exec(searchable); + if (idMatch && idMatch[1] !== "defineCapability") { + return idMatch[1]; + } + const asDefault = /export\s*\{[^}]*?\b([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+default\b/.exec( + searchable, + ); + return asDefault ? asDefault[1] : null; +} + +/** + * Scan an object literal body for its top-level properties, returning a map + * of property name → raw value text. Depth-aware and quote/comment-aware so + * nested schema annotations (e.g. a `description` inside `input`) are never + * mistaken for capability fields. + */ +export function scanTopLevelProperties(objectBody: string): Map { + const properties = new Map(); + let index = 0; + + while (index < objectBody.length) { + index = skipInsignificant(objectBody, index); + if (index >= objectBody.length) break; + + // Property key: identifier or quoted string. + let key: string | null = null; + const char = objectBody[index]; + if (char === '"' || char === "'") { + const end = findStringEnd(objectBody, index); + if (end === -1) break; + key = objectBody.slice(index + 1, end); + index = end + 1; + } else { + const match = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(objectBody.slice(index)); + if (!match) break; + key = match[0]; + index += match[0].length; + } + + index = skipInsignificant(objectBody, index); + if (objectBody[index] !== ":") { + // Shorthand or method definitions — skip to the next top-level comma. + index = skipToTopLevelComma(objectBody, index) + 1; + continue; + } + index += 1; + + const valueStart = skipInsignificant(objectBody, index); + const valueEnd = skipToTopLevelComma(objectBody, valueStart); + properties.set(key, objectBody.slice(valueStart, valueEnd).trim()); + index = valueEnd + 1; + } + + return properties; +} + +/** Parse the `capabilities: { ... }` block of an app manifest source. */ +export function extractCapabilityRegistrations( + manifestSource: string, +): { name: string; file: string }[] { + const appBody = extractDefineAppObjectBody(manifestSource); + if (!appBody) return []; + const capabilitiesValue = scanTopLevelProperties(appBody).get("capabilities"); + if (!capabilitiesValue) return []; + const braceStart = skipInsignificant(capabilitiesValue, 0); + if (capabilitiesValue[braceStart] !== "{") return []; + const braceEnd = findMatchingBrace(capabilitiesValue, braceStart, "{", "}"); + if (braceEnd === -1) return []; + const block = capabilitiesValue.slice(braceStart + 1, braceEnd); + const searchableBlock = maskComments(block); + + const entries: { name: string; file: string }[] = []; + // Keys are usually quoted ("notes.search"); values are either lazy import + // functions or plain string paths (post-transform form). + const pattern = + /(?:(["'])((?:\\.|(?!\1).)+)\1|([A-Za-z0-9_$]+))\s*:\s*(?:\(\)\s*=>\s*import\(\s*(["'])([^"']+)\4\s*\)|(["'])([^"']+)\6)/g; + for (const match of searchableBlock.matchAll(pattern)) { + entries.push({ name: match[2] ?? match[3], file: match[5] ?? match[7] }); + } + return entries; +} + +/** Extract the inline object body passed to the exported app's `defineApp()`. */ +export function extractDefineAppObjectBody(source: string): string | null { + const searchable = maskCommentsAndStrings(source); + const defaultExport = /export\s+default\s+defineApp\s*(?:<[^(]*?>)?\s*\(/.exec(searchable); + let parenIndex = + defaultExport?.index != null ? defaultExport.index + defaultExport[0].length - 1 : -1; + + if (parenIndex === -1) { + const declaration = /export\s+(?:const|let|var)\s+app\b/g; + for (const match of searchable.matchAll(declaration)) { + if (match.index == null || braceDepthAt(searchable, match.index) !== 0) continue; + parenIndex = findCallInitializer(searchable, match.index + match[0].length, "defineApp"); + if (parenIndex !== -1) break; + } + } + + if (parenIndex === -1) { + const localName = namedAppExportLocalName(searchable); + if (localName) { + const id = localName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const declaration = new RegExp(`\\b(?:const|let|var)\\s+${id}\\b`, "g"); + for (const match of searchable.matchAll(declaration)) { + if (match.index == null || braceDepthAt(searchable, match.index) !== 0) continue; + parenIndex = findCallInitializer(searchable, match.index + match[0].length, "defineApp"); + if (parenIndex !== -1) break; + } + } + } + + if (parenIndex === -1) return null; + const braceStart = skipInsignificant(source, parenIndex + 1); + if (source[braceStart] !== "{") return null; + const braceEnd = findMatchingBrace(source, braceStart, "{", "}"); + return braceEnd === -1 ? null : source.slice(braceStart + 1, braceEnd); +} + +function namedAppExportLocalName(searchable: string): string | null { + const aliased = /export\s*\{[^}]*?\b([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+app\b/.exec(searchable); + if (aliased) return aliased[1]; + return /export\s*\{[^}]*?\bapp\b(?:\s*,|\s*\})/.test(searchable) ? "app" : null; +} + +/** + * Find the raw text of a top-level-ish `key: { ... }` property anywhere in a + * source file (used for the manifest's `capabilities` block). + */ +export function findTopLevelObjectProperty(source: string, key: string): string | null { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const codeOnly = maskCommentsAndStrings(source); + const commentsRemoved = maskComments(source); + const unquotedMatch = new RegExp(`\\b${escapedKey}\\s*:\\s*\\{`).exec(codeOnly); + const quotedIndex = findQuotedObjectProperty(source, key); + const matchIndex = [unquotedMatch?.index, quotedIndex] + .filter((candidate): candidate is number => candidate !== undefined && candidate !== null) + .sort((left, right) => left - right)[0]; + if (matchIndex === undefined) return null; + const braceStart = commentsRemoved.indexOf("{", matchIndex); + const braceEnd = findMatchingBrace(source, braceStart, "{", "}"); + if (braceEnd === -1) return null; + return source.slice(braceStart + 1, braceEnd); +} + +/** Parse an extracted data literal without evaluating application code. */ +export function evaluateLiteral(expression: string): unknown { + const parsed = parseLiteralValue(expression, 0); + if (!parsed) return undefined; + const end = skipInsignificant(expression, parsed.index); + return end === expression.length ? parsed.value : undefined; +} + +function skipToTopLevelComma(source: string, start: number): number { + let depth = 0; + let index = start; + while (index < source.length) { + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + const end = findStringEnd(source, index); + if (end === -1) return source.length; + index = end + 1; + continue; + } + if (char === "/" && (source[index + 1] === "/" || source[index + 1] === "*")) { + index = skipInsignificant(source, index); + continue; + } + if (char === "/") { + const regexEnd = regexLiteralEnd(source, index); + if (regexEnd !== -1) { + index = regexEnd; + continue; + } + } + if (char === "{" || char === "[" || char === "(") depth += 1; + if (char === "}" || char === "]" || char === ")") depth -= 1; + if (char === "," && depth === 0) return index; + index += 1; + } + return source.length; +} + +function skipInsignificant(source: string, start: number): number { + let index = start; + while (index < source.length) { + const char = source[index]; + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + index += 1; + continue; + } + if (char === "/" && source[index + 1] === "/") { + const lineEnd = source.indexOf("\n", index); + index = lineEnd === -1 ? source.length : lineEnd + 1; + continue; + } + if (char === "/" && source[index + 1] === "*") { + const blockEnd = source.indexOf("*/", index + 2); + index = blockEnd === -1 ? source.length : blockEnd + 2; + continue; + } + break; + } + return index; +} + +/** + * Replace comments, regex literals, and optionally strings with spaces while + * preserving source offsets. Regex-based entry-point discovery can then only + * match live code, while the real source remains available for brace-aware + * extraction. + */ +function maskLexicalNoise(source: string, maskStrings: boolean): string { + const chars = source.split(""); + let index = 0; + while (index < source.length) { + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + const end = findStringEnd(source, index); + if (end === -1) return chars.slice(0, index).join("") + " ".repeat(source.length - index); + if (maskStrings) { + for (let cursor = index; cursor <= end; cursor += 1) { + if (chars[cursor] !== "\n" && chars[cursor] !== "\r") chars[cursor] = " "; + } + } + index = end + 1; + continue; + } + if (char === "/" && source[index + 1] === "/") { + const end = source.indexOf("\n", index + 2); + const limit = end === -1 ? source.length : end; + for (let cursor = index; cursor < limit; cursor += 1) chars[cursor] = " "; + index = limit; + continue; + } + if (char === "/" && source[index + 1] === "*") { + const close = source.indexOf("*/", index + 2); + const limit = close === -1 ? source.length : close + 2; + for (let cursor = index; cursor < limit; cursor += 1) { + if (chars[cursor] !== "\n" && chars[cursor] !== "\r") chars[cursor] = " "; + } + index = limit; + continue; + } + if (char === "/") { + const end = regexLiteralEnd(source, index); + if (end !== -1) { + for (let cursor = index; cursor < end; cursor += 1) { + if (chars[cursor] !== "\n" && chars[cursor] !== "\r") chars[cursor] = " "; + } + index = end; + continue; + } + } + index += 1; + } + return chars.join(""); +} + +function maskComments(source: string): string { + return maskLexicalNoise(source, false); +} + +function maskCommentsAndStrings(source: string): string { + return maskLexicalNoise(source, true); +} + +/** Find an actual quoted property token, excluding lookalikes inside strings/comments. */ +function findQuotedObjectProperty(source: string, key: string): number | null { + let index = 0; + while (index < source.length) { + const next = skipInsignificant(source, index); + if (next > index) { + index = next; + continue; + } + + const char = source[index]; + if (char !== '"' && char !== "'" && char !== "`") { + index += 1; + continue; + } + + const end = findStringEnd(source, index); + if (end === -1) return null; + if (char !== "`" && source.slice(index + 1, end) === key) { + const colon = skipInsignificant(source, end + 1); + const brace = source[colon] === ":" ? skipInsignificant(source, colon + 1) : -1; + if (brace !== -1 && source[brace] === "{") return index; + } + index = end + 1; + } + return null; +} + +/** Index of the closing quote of the string starting at `start`. */ +function findStringEnd(source: string, start: number): number { + const quote = source[start]; + if (quote === "`") return findTemplateEnd(source, start); + for (let index = start + 1; index < source.length; index += 1) { + const char = source[index]; + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) return index; + } + return -1; +} + +/** + * Index of the closing backtick of the template literal starting at `start`. + * Tracks `${ ... }` interpolations (including nested strings and templates + * inside them) so an inner backtick or `}` does not end the template early. + */ +function findTemplateEnd(source: string, start: number): number { + for (let index = start + 1; index < source.length; index += 1) { + const char = source[index]; + if (char === "\\") { + index += 1; + continue; + } + if (char === "`") return index; + if (char === "$" && source[index + 1] === "{") { + let depth = 1; + index += 2; + while (index < source.length && depth > 0) { + const inner = source[index]; + if (inner === "\\") { + index += 2; + continue; + } + if (inner === '"' || inner === "'" || inner === "`") { + const end = findStringEnd(source, index); + if (end === -1) return -1; + index = end + 1; + continue; + } + if (inner === "{") depth += 1; + else if (inner === "}") depth -= 1; + index += 1; + } + if (depth > 0) return -1; + index -= 1; + } + } + return -1; +} + +function findMatchingBrace(source: string, start: number, open: string, close: string): number { + let depth = 0; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + const end = findStringEnd(source, index); + if (end === -1) return -1; + index = end; + continue; + } + if (char === "/" && (source[index + 1] === "/" || source[index + 1] === "*")) { + index = skipInsignificant(source, index) - 1; + continue; + } + if (char === "/") { + const regexEnd = regexLiteralEnd(source, index); + if (regexEnd !== -1) { + index = regexEnd - 1; + continue; + } + } + if (char === open) depth += 1; + if (char === close) { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +const REGEX_PRECEDING_PUNCTUATION = new Set([ + "(", + ",", + "=", + ":", + "[", + "!", + "&", + "|", + "?", + "{", + "}", + ";", + "<", + ">", + "+", + "-", + "*", + "%", + "^", + "~", +]); +const REGEX_PRECEDING_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "do", + "else", + "yield", + "await", + "case", +]); +const REGEX_STATEMENT_CONTROL_KEYWORDS = new Set(["if", "while", "for", "with"]); + +interface LexicalToken { + kind: "atom" | "punctuation" | "word"; + value: string; +} + +/** + * Whether `closeIndex` closes a control-flow condition whose body may begin + * with a regex expression statement (`if (condition) /pattern/.test(value)`). + * + * A closing parenthesis normally makes the following slash division. Control + * statements are the exception, so retain just enough token context while + * matching parentheses to distinguish them from calls such as `fn() / 2`. + */ +function closesRegexStatementControlParen(source: string, closeIndex: number): boolean { + const controlParens: boolean[] = []; + const tokens: LexicalToken[] = []; + + const record = (token: LexicalToken): void => { + tokens.push(token); + if (tokens.length > 2) tokens.shift(); + }; + + for (let index = 0; index <= closeIndex; index += 1) { + const char = source[index]; + if (/\s/.test(char)) continue; + + if (char === '"' || char === "'" || char === "`") { + const end = findStringEnd(source, index); + if (end === -1) return false; + record({ kind: "atom", value: "string" }); + index = end; + continue; + } + if (char === "/" && source[index + 1] === "/") { + const end = source.indexOf("\n", index + 2); + index = end === -1 ? source.length : end; + continue; + } + if (char === "/" && source[index + 1] === "*") { + const end = source.indexOf("*/", index + 2); + if (end === -1) return false; + index = end + 1; + continue; + } + if (char === "/") { + const end = regexLiteralEnd(source, index); + if (end !== -1) { + record({ kind: "atom", value: "regex" }); + index = end - 1; + continue; + } + record({ kind: "punctuation", value: char }); + continue; + } + if (/[A-Za-z_$]/.test(char)) { + let end = index + 1; + while (end < source.length && /[A-Za-z0-9_$]/.test(source[end])) end += 1; + record({ kind: "word", value: source.slice(index, end) }); + index = end - 1; + continue; + } + if (/[0-9]/.test(char)) { + let end = index + 1; + while (end < source.length && /[A-Za-z0-9_.]/.test(source[end])) end += 1; + record({ kind: "atom", value: source.slice(index, end) }); + index = end - 1; + continue; + } + if (char === "(") { + const previous = tokens[tokens.length - 1]; + const beforePrevious = tokens[tokens.length - 2]; + const followsControlKeyword = + previous?.kind === "word" && + (REGEX_STATEMENT_CONTROL_KEYWORDS.has(previous.value) || + (previous.value === "await" && + beforePrevious?.kind === "word" && + beforePrevious.value === "for")) && + beforePrevious?.value !== "."; + controlParens.push(followsControlKeyword); + record({ kind: "punctuation", value: char }); + continue; + } + if (char === ")") { + const closesControl = controlParens.pop() ?? false; + if (index === closeIndex) return closesControl; + record({ kind: "punctuation", value: char }); + continue; + } + + record({ kind: "punctuation", value: char }); + } + + return false; +} + +/** + * If the `/` at `slashIndex` begins a regex literal (decided from the previous + * significant token, the standard divide-vs-regex heuristic), return the index + * just after its closing `/` and flags; otherwise -1. Keeps the brace/comma + * scanners from miscounting a `}`/`]`/`,` inside a regex such as `/\}/`. + */ +function regexLiteralEnd(source: string, slashIndex: number): number { + let back = slashIndex - 1; + while (back >= 0 && /\s/.test(source[back])) back -= 1; + let isRegex: boolean; + if (back < 0) { + isRegex = true; + } else { + const prev = source[back]; + if (REGEX_PRECEDING_PUNCTUATION.has(prev)) { + isRegex = true; + } else if (prev === ")" && closesRegexStatementControlParen(source, back)) { + isRegex = true; + } else if (/[A-Za-z0-9_$]/.test(prev)) { + let wordStart = back; + while (wordStart >= 0 && /[A-Za-z0-9_$]/.test(source[wordStart])) wordStart -= 1; + isRegex = REGEX_PRECEDING_KEYWORDS.has(source.slice(wordStart + 1, back + 1)); + } else { + // Non-control `)`, `]`, `.`, numbers → division operator, not a regex. + isRegex = false; + } + } + if (!isRegex) return -1; + + let index = slashIndex + 1; + let inClass = false; + while (index < source.length) { + const char = source[index]; + if (char === "\\") { + index += 2; + continue; + } + if (char === "\n") return -1; + if (char === "[") inClass = true; + else if (char === "]") inClass = false; + else if (char === "/" && !inClass) { + index += 1; + while (index < source.length && /[a-z]/i.test(source[index])) index += 1; + return index; + } + index += 1; + } + return -1; +} + +interface ParsedLiteral { + value: unknown; + index: number; +} + +function parseLiteralValue(source: string, start: number): ParsedLiteral | null { + const index = skipInsignificant(source, start); + const char = source[index]; + if (char === "{") return parseObjectLiteral(source, index); + if (char === "[") return parseArrayLiteral(source, index); + if (char === '"' || char === "'" || char === "`") return parseStringLiteral(source, index); + if (source.startsWith("true", index)) return parseKeyword(source, index, "true", true); + if (source.startsWith("false", index)) return parseKeyword(source, index, "false", false); + if (source.startsWith("null", index)) return parseKeyword(source, index, "null", null); + return parseNumberLiteral(source, index); +} + +function parseObjectLiteral(source: string, start: number): ParsedLiteral | null { + const value: Record = {}; + let index = skipInsignificant(source, start + 1); + if (source[index] === "}") return { value, index: index + 1 }; + + while (index < source.length) { + let key: string | null = null; + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + const parsedKey = parseStringLiteral(source, index); + if (!parsedKey || typeof parsedKey.value !== "string") return null; + key = parsedKey.value; + index = parsedKey.index; + } else { + const match = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(source.slice(index)); + if (!match) return null; + key = match[0]; + index += match[0].length; + } + + index = skipInsignificant(source, index); + if (source[index] !== ":") return null; + + const parsedValue = parseLiteralValue(source, index + 1); + if (!parsedValue) return null; + value[key] = parsedValue.value; + + index = skipInsignificant(source, parsedValue.index); + if (source[index] === "}") return { value, index: index + 1 }; + if (source[index] !== ",") return null; + index = skipInsignificant(source, index + 1); + if (source[index] === "}") return { value, index: index + 1 }; + } + + return null; +} + +function parseArrayLiteral(source: string, start: number): ParsedLiteral | null { + const value: unknown[] = []; + let index = skipInsignificant(source, start + 1); + if (source[index] === "]") return { value, index: index + 1 }; + + while (index < source.length) { + const parsedValue = parseLiteralValue(source, index); + if (!parsedValue) return null; + value.push(parsedValue.value); + + index = skipInsignificant(source, parsedValue.index); + if (source[index] === "]") return { value, index: index + 1 }; + if (source[index] !== ",") return null; + index = skipInsignificant(source, index + 1); + if (source[index] === "]") return { value, index: index + 1 }; + } + + return null; +} + +function parseStringLiteral(source: string, start: number): ParsedLiteral | null { + const quote = source[start]; + const end = findStringEnd(source, start); + if (end === -1) return null; + const body = source.slice(start + 1, end); + if (quote === "`" && body.includes("${")) return null; + + let value = ""; + for (let index = 0; index < body.length; index += 1) { + const char = body[index]; + if (char !== "\\") { + value += char; + continue; + } + + index += 1; + if (index >= body.length) return null; + const escaped = body[index]; + switch (escaped) { + case "b": + value += "\b"; + break; + case "f": + value += "\f"; + break; + case "n": + value += "\n"; + break; + case "r": + value += "\r"; + break; + case "t": + value += "\t"; + break; + case "v": + value += "\v"; + break; + case "0": + value += "\0"; + break; + case "x": { + const hex = body.slice(index + 1, index + 3); + if (!/^[0-9a-fA-F]{2}$/.test(hex)) return null; + value += String.fromCharCode(Number.parseInt(hex, 16)); + index += 2; + break; + } + case "u": { + if (body[index + 1] === "{") { + const close = body.indexOf("}", index + 2); + if (close === -1) return null; + const hex = body.slice(index + 2, close); + if (!/^[0-9a-fA-F]+$/.test(hex)) return null; + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff) return null; + value += String.fromCodePoint(codePoint); + index = close; + break; + } + const hex = body.slice(index + 1, index + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null; + value += String.fromCharCode(Number.parseInt(hex, 16)); + index += 4; + break; + } + default: + value += escaped; + break; + } + } + + return { value, index: end + 1 }; +} + +function parseKeyword( + source: string, + start: number, + keyword: string, + value: unknown, +): ParsedLiteral | null { + const end = start + keyword.length; + return /[A-Za-z0-9_$]/.test(source[end] ?? "") ? null : { value, index: end }; +} + +function parseNumberLiteral(source: string, start: number): ParsedLiteral | null { + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(source.slice(start)); + if (!match) return null; + const end = start + match[0].length; + if (/[A-Za-z0-9_$]/.test(source[end] ?? "")) return null; + return { value: Number(match[0]), index: end }; +} diff --git a/packages/capabilities/test/capability.test.ts b/packages/capabilities/test/capability.test.ts new file mode 100644 index 00000000..7aaa451b --- /dev/null +++ b/packages/capabilities/test/capability.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; + +import { defineCapability } from "../src/index.ts"; + +const baseDefinition = { + title: "Search notes", + description: "Find notes.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array", items: { type: "string" } } }, + required: ["notes"], + }, + effect: "read" as const, + run: async () => ({ notes: [] }), +}; + +describe("defineCapability", () => { + it("normalizes the definition and defaults middleware/expose", () => { + const capability = defineCapability(baseDefinition); + + expect(capability.kind).toBe("capability"); + expect(capability.middleware).toEqual([]); + expect(capability.expose).toBeNull(); + }); + + it("normalizes expose.http: true to a POST exposure", () => { + const capability = defineCapability({ ...baseDefinition, expose: { http: true } }); + expect(capability.expose).toEqual({ http: { method: "POST" }, mcp: false, webmcp: false }); + }); + + it("keeps custom HTTP paths", () => { + const capability = defineCapability({ + ...baseDefinition, + expose: { http: { path: "/api/search-notes" } }, + }); + expect(capability.expose?.http).toEqual({ method: "POST", path: "/api/search-notes" }); + }); + + it("applies input defaults before validating", () => { + const capability = defineCapability(baseDefinition); + const result = capability.validateInput({ query: "x" }); + expect(result).toEqual({ ok: true, value: { query: "x", limit: 10 } }); + }); + + it("preserves explicit null input for null schemas", () => { + const capability = defineCapability({ + ...baseDefinition, + input: { type: "null" }, + }); + expect(capability.validateInput(null)).toEqual({ ok: true, value: null }); + }); + + it("returns path-scoped input issues", () => { + const capability = defineCapability(baseDefinition); + const result = capability.validateInput({ query: "x", limit: 99 }); + expect(result).toEqual({ + ok: false, + issues: [{ path: "/limit", message: "must be <= 50" }], + }); + }); + + it("validates output", () => { + const capability = defineCapability(baseDefinition); + expect(capability.validateOutput({ notes: ["a"] })).toEqual({ + ok: true, + value: { notes: ["a"] }, + }); + expect(capability.validateOutput({})).toEqual({ + ok: false, + issues: [{ path: "/notes", message: "is required" }], + }); + }); + + it("rejects missing contract fields", () => { + expect(() => defineCapability({ ...baseDefinition, description: " " })).toThrow( + /"description" must be a non-empty string/, + ); + expect(() => + defineCapability({ ...baseDefinition, effect: "delete" as unknown as "read" }), + ).toThrow(/"effect" must be "read", "write", or "destructive"/); + expect(() => + defineCapability({ ...baseDefinition, input: undefined as unknown as {} }), + ).toThrow(/"input" must be a JSON Schema object/); + }); + + it("rejects schemas using unsupported keywords, naming them", () => { + expect(() => + defineCapability({ + ...baseDefinition, + input: { + type: "object", + properties: { query: { type: "string", pattern: "^a" } }, + }, + }), + ).toThrow(/unsupported JSON Schema keywords: \/properties\/query\/pattern/); + }); + + it("rejects malformed values for supported schema keywords", () => { + expect(() => + defineCapability({ + ...baseDefinition, + input: { type: 123 }, + }), + ).toThrow(/invalid JSON Schema values: \/type:/); + expect(() => + defineCapability({ + ...baseDefinition, + input: { type: "object", required: "query" }, + }), + ).toThrow(/\/required:/); + }); + + it("rejects exposing destructive capabilities to agent projections", () => { + expect(() => + defineCapability({ + ...baseDefinition, + effect: "destructive", + expose: { http: true, webmcp: true }, + }), + ).toThrow(/destructive capabilities cannot be exposed to agent projections/); + expect(() => + defineCapability({ + ...baseDefinition, + effect: "destructive", + expose: { http: true, mcp: true }, + }), + ).toThrow(/destructive capabilities cannot be exposed to agent projections/); + }); + + it("allows destructive capabilities over HTTP (confirmation-gated at runtime)", () => { + const capability = defineCapability({ + ...baseDefinition, + effect: "destructive", + expose: { http: true }, + }); + expect(capability.expose?.http).toEqual({ method: "POST" }); + }); + + it("allows private destructive capabilities", () => { + const capability = defineCapability({ ...baseDefinition, effect: "destructive" }); + expect(capability.expose).toBeNull(); + }); + + it("records agentPolicy and rejects invalid values", () => { + const capability = defineCapability({ ...baseDefinition, agentPolicy: "require" }); + expect(capability.agentPolicy).toBe("require"); + expect(() => + defineCapability({ ...baseDefinition, agentPolicy: "always" as unknown as "require" }), + ).toThrow(/"agentPolicy" must be "observe" or "require"/); + }); + + it("rejects webmcp exposure without http", () => { + expect(() => defineCapability({ ...baseDefinition, expose: { webmcp: true } })).toThrow( + /expose\.webmcp requires expose\.http/, + ); + }); + + it("rejects non-POST HTTP methods and invalid paths", () => { + expect(() => + defineCapability({ + ...baseDefinition, + expose: { http: { method: "GET" as unknown as "POST" } }, + }), + ).toThrow(/only supports method: "POST"/); + expect(() => + defineCapability({ ...baseDefinition, expose: { http: { path: "no-slash" } } }), + ).toThrow(/exact same-origin pathname/); + }); + + it("rejects custom HTTP paths that URL parsing could reinterpret", () => { + for (const path of [ + "//evil.test/collect", + "/\\evil.test/collect", + "/api/../collect", + "/api/collect?target=evil", + "/api/collect#fragment", + "/\t/evil.test/collect", + ]) { + expect(() => defineCapability({ ...baseDefinition, expose: { http: { path } } })).toThrow( + /exact same-origin pathname/, + ); + } + }); +}); diff --git a/packages/capabilities/test/form.test.ts b/packages/capabilities/test/form.test.ts new file mode 100644 index 00000000..561ca483 --- /dev/null +++ b/packages/capabilities/test/form.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { coerceFormInput } from "../src/form.ts"; + +const SCHEMA = { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "integer" }, + price: { type: "number" }, + urgent: { type: "boolean" }, + note: { type: "null" }, + tags: { type: "array", items: { type: "string" } }, + counts: { type: "array", items: { type: "integer" } }, + }, +} as const; + +describe("coerceFormInput", () => { + it("parses numbers and integers, leaving unparseable values for validation", () => { + const result = coerceFormInput(SCHEMA, [ + ["limit", "5"], + ["price", "9.99"], + ["query", "42"], + ]); + expect(result).toEqual({ limit: 5, price: 9.99, query: "42" }); + + // Not a number: passes through so schema validation reports the real issue. + expect(coerceFormInput(SCHEMA, [["limit", "many"]])).toEqual({ limit: "many" }); + expect(coerceFormInput(SCHEMA, [["limit", " "]])).toEqual({ limit: " " }); + }); + + it('treats checkbox "on" and "true"/"false" as booleans', () => { + expect(coerceFormInput(SCHEMA, [["urgent", "on"]])).toEqual({ urgent: true }); + expect(coerceFormInput(SCHEMA, [["urgent", "true"]])).toEqual({ urgent: true }); + expect(coerceFormInput(SCHEMA, [["urgent", "false"]])).toEqual({ urgent: false }); + expect(coerceFormInput(SCHEMA, [["urgent", "maybe"]])).toEqual({ urgent: "maybe" }); + }); + + it("collects repeated fields into arrays when the schema says array", () => { + const result = coerceFormInput(SCHEMA, [ + ["tags", "a"], + ["tags", "b"], + ["counts", "1"], + ["counts", "2"], + ]); + expect(result).toEqual({ tags: ["a", "b"], counts: [1, 2] }); + + // A single entry still becomes a one-element array. + expect(coerceFormInput(SCHEMA, [["tags", "solo"]])).toEqual({ tags: ["solo"] }); + }); + + it("collapses repeated non-array fields to the last value", () => { + expect( + coerceFormInput(SCHEMA, [ + ["query", "first"], + ["query", "second"], + ]), + ).toEqual({ query: "second" }); + }); + + it("passes unknown fields and non-string values through unchanged", () => { + const blob = new Blob(["x"]); + expect( + coerceFormInput(SCHEMA, [ + ["unknown", "value"], + ["query", blob], + ]), + ).toEqual({ unknown: "value", query: blob }); + }); + + it("tolerates schemas without properties", () => { + expect(coerceFormInput({ type: "object" }, [["a", "1"]])).toEqual({ a: "1" }); + expect(coerceFormInput(null, [["a", "1"]])).toEqual({ a: "1" }); + }); + + it("keeps prototype-named fields as own properties so validation can reject them", () => { + const result = coerceFormInput(SCHEMA, [ + ["__proto__", "polluted"], + ["constructor", "polluted"], + ["toString", "polluted"], + ]); + + // Each field survives as a real own property rather than disappearing into + // a prototype setter — `additionalProperties: false` must be able to see it. + expect(Object.hasOwn(result, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(result, "__proto__")?.value).toBe("polluted"); + expect(result.constructor).toBe("polluted"); + expect(result.toString).toBe("polluted"); + + // Nothing leaked onto the prototype chain. + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(({} as Record).toString).toBeTypeOf("function"); + }); + + it("does not treat inherited members as a field's schema", () => { + // `properties.constructor` only exists on Object.prototype; an own-property + // lookup must not resolve it, so the value stays an uncoerced string. + expect(coerceFormInput(SCHEMA, [["constructor", "5"]])).toMatchObject({ constructor: "5" }); + }); +}); diff --git a/packages/capabilities/test/schema-type-text.test.ts b/packages/capabilities/test/schema-type-text.test.ts new file mode 100644 index 00000000..942b980c --- /dev/null +++ b/packages/capabilities/test/schema-type-text.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { schemaToTypeText } from "../src/schema-type-text.ts"; + +describe("schemaToTypeText", () => { + it("maps primitive types", () => { + expect(schemaToTypeText({ type: "string" }, "input")).toBe("string"); + expect(schemaToTypeText({ type: "number" }, "input")).toBe("number"); + expect(schemaToTypeText({ type: "integer" }, "input")).toBe("number"); + expect(schemaToTypeText({ type: "boolean" }, "input")).toBe("boolean"); + expect(schemaToTypeText({ type: "null" }, "input")).toBe("null"); + }); + + it("falls back to unknown for missing or unrecognized schemas", () => { + expect(schemaToTypeText(null, "input")).toBe("unknown"); + expect(schemaToTypeText({}, "input")).toBe("unknown"); + expect(schemaToTypeText("not a schema", "output")).toBe("unknown"); + }); + + it("renders enum and const as literal types", () => { + expect(schemaToTypeText({ enum: ["draft", "published", 3, null] }, "input")).toBe( + '"draft" | "published" | 3 | null', + ); + expect(schemaToTypeText({ const: true }, "output")).toBe("true"); + expect(schemaToTypeText({ const: { tag: ["a", 1] } }, "output")).toBe('{ "tag": ["a", 1]; }'); + }); + + it("renders arrays through their item schema", () => { + expect(schemaToTypeText({ type: "array", items: { type: "string" } }, "input")).toBe( + "Array", + ); + expect(schemaToTypeText({ type: "array" }, "input")).toBe("Array"); + }); + + it("closes objects with additionalProperties: false and keeps open ones indexed", () => { + const closed = { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }; + expect(schemaToTypeText(closed, "input")).toBe('{ "query": string; }'); + + const open = { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }; + expect(schemaToTypeText(open, "input")).toBe('{ "query": string; [key: string]: unknown; }'); + }); + + it("renders property-less objects as Records", () => { + expect(schemaToTypeText({ type: "object" }, "output")).toBe("Record"); + expect(schemaToTypeText({ type: "object", additionalProperties: false }, "output")).toBe( + "Record", + ); + expect( + schemaToTypeText({ type: "object", additionalProperties: { type: "number" } }, "output"), + ).toBe("Record"); + }); + + it("treats defaulted input properties as optional, but not output properties", () => { + const schema = { + type: "object", + properties: { + query: { type: "string" }, + limit: { type: "integer", default: 10 }, + }, + required: ["query", "limit"], + additionalProperties: false, + }; + // Defaults are applied before input validation, so callers may omit `limit`. + expect(schemaToTypeText(schema, "input")).toBe('{ "query": string; "limit"?: number; }'); + // Output validation applies no defaults: required means present. + expect(schemaToTypeText(schema, "output")).toBe('{ "query": string; "limit": number; }'); + }); + + it("marks non-required properties optional in both positions", () => { + const schema = { + type: "object", + properties: { cursor: { type: "string" } }, + additionalProperties: false, + }; + expect(schemaToTypeText(schema, "input")).toBe('{ "cursor"?: string; }'); + expect(schemaToTypeText(schema, "output")).toBe('{ "cursor"?: string; }'); + }); + + it("renders nested structures", () => { + const schema = { + type: "object", + properties: { + notes: { + type: "array", + items: { + type: "object", + properties: { id: { type: "string" }, title: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + }, + }, + required: ["notes"], + additionalProperties: false, + }; + expect(schemaToTypeText(schema, "output")).toBe( + '{ "notes": Array<{ "id": string; "title"?: string; }>; }', + ); + }); +}); diff --git a/packages/capabilities/test/schema.test.ts b/packages/capabilities/test/schema.test.ts new file mode 100644 index 00000000..db856bc0 --- /dev/null +++ b/packages/capabilities/test/schema.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; + +import { + applySchemaDefaults, + collectInvalidSchemaKeywordValues, + collectUnsupportedSchemaKeywords, + validateAgainstSchema, +} from "../src/schema.ts"; + +describe("validateAgainstSchema", () => { + const schema = { + type: "object", + properties: { + query: { type: "string", minLength: 1, maxLength: 10 }, + limit: { type: "integer", minimum: 1, maximum: 50 }, + tags: { type: "array", items: { type: "string" } }, + status: { enum: ["draft", "published"] }, + kind: { const: "note" }, + nested: { + type: "object", + properties: { flag: { type: "boolean" } }, + required: ["flag"], + }, + }, + required: ["query"], + additionalProperties: false, + }; + + it("accepts a conforming value", () => { + expect( + validateAgainstSchema(schema, { + query: "hello", + limit: 10, + tags: ["a", "b"], + status: "draft", + kind: "note", + nested: { flag: true }, + }), + ).toEqual([]); + }); + + it("reports missing required properties with a path", () => { + const issues = validateAgainstSchema(schema, {}); + expect(issues).toEqual([{ path: "/query", message: "is required" }]); + }); + + it("does not satisfy required properties through Object.prototype", () => { + expect(validateAgainstSchema({ type: "object", required: ["constructor"] }, {})).toEqual([ + { path: "/constructor", message: "is required" }, + ]); + }); + + it("reports type mismatches with the actual type", () => { + const issues = validateAgainstSchema(schema, { query: 42 }); + expect(issues).toEqual([{ path: "/query", message: "must be of type string, got number" }]); + }); + + it("rejects JavaScript objects outside the JSON data model", () => { + class Instance {} + + for (const value of [new Date(), new Map(), new Instance()]) { + expect(validateAgainstSchema({ type: "object" }, value)).toEqual([ + { path: "", message: "must be JSON-serializable, got object" }, + ]); + } + expect(validateAgainstSchema({ type: "object" }, Object.create(null))).toEqual([]); + }); + + it("rejects non-JSON values through unconstrained and additional properties", () => { + expect(validateAgainstSchema({}, { upload: new Blob(["data"]) })).toEqual([ + { path: "/upload", message: "must be JSON-serializable, got object" }, + ]); + expect(validateAgainstSchema({ type: "object" }, { value: undefined })).toEqual([ + { path: "/value", message: "must be JSON-serializable, got undefined" }, + ]); + + const circular: Record = {}; + circular.self = circular; + expect(validateAgainstSchema({}, circular)).toEqual([ + { path: "/self", message: "must be JSON-serializable, got a circular reference" }, + ]); + + const sparse = Array(1); + expect(validateAgainstSchema({}, sparse)).toEqual([ + { path: "/0", message: "must be JSON-serializable, got a sparse array slot" }, + ]); + }); + + it("enforces integer vs number", () => { + expect(validateAgainstSchema(schema, { query: "x", limit: 1.5 })).toEqual([ + { path: "/limit", message: "must be of type integer, got number" }, + ]); + }); + + it("enforces minimum/maximum and minLength/maxLength", () => { + expect(validateAgainstSchema(schema, { query: "x", limit: 99 })).toEqual([ + { path: "/limit", message: "must be <= 50" }, + ]); + expect(validateAgainstSchema(schema, { query: "" })).toEqual([ + { path: "/query", message: "must be at least 1 character(s) long" }, + ]); + expect(validateAgainstSchema(schema, { query: "way-too-long-string" })).toEqual([ + { path: "/query", message: "must be at most 10 character(s) long" }, + ]); + }); + + it("counts Unicode code points for minLength and maxLength", () => { + expect(validateAgainstSchema({ type: "string", minLength: 2 }, "😀")).toEqual([ + { path: "", message: "must be at least 2 character(s) long" }, + ]); + expect(validateAgainstSchema({ type: "string", maxLength: 1 }, "😀")).toEqual([]); + }); + + it("rejects unknown properties when additionalProperties is false", () => { + expect(validateAgainstSchema(schema, { query: "x", bogus: 1 })).toEqual([ + { path: "/bogus", message: "is not an allowed property" }, + ]); + }); + + it("rejects prototype-named properties unless explicitly declared", () => { + const closedSchema = { type: "object", properties: {}, additionalProperties: false }; + for (const name of ["constructor", "toString", "__proto__"]) { + const input = JSON.parse(`{${JSON.stringify(name)}:1}`); + expect(validateAgainstSchema(closedSchema, input)).toEqual([ + { path: `/${name}`, message: "is not an allowed property" }, + ]); + } + }); + + it("validates array items with indexed paths", () => { + expect(validateAgainstSchema(schema, { query: "x", tags: ["ok", 7] })).toEqual([ + { path: "/tags/1", message: "must be of type string, got number" }, + ]); + }); + + it("validates enum and const", () => { + expect(validateAgainstSchema(schema, { query: "x", status: "archived" })).toEqual([ + { path: "/status", message: 'must be one of "draft", "published"' }, + ]); + expect(validateAgainstSchema(schema, { query: "x", kind: "todo" })).toEqual([ + { path: "/kind", message: 'must equal "note"' }, + ]); + }); + + it("recurses into nested objects", () => { + expect(validateAgainstSchema(schema, { query: "x", nested: {} })).toEqual([ + { path: "/nested/flag", message: "is required" }, + ]); + }); + + it("does not accept a __proto__ payload as an object-valued const", () => { + const constSchema = { const: { x: 1 } }; + const proto = JSON.parse('{"__proto__": {}}') as Record; + expect(validateAgainstSchema(constSchema, proto)).not.toEqual([]); + expect(validateAgainstSchema(constSchema, { x: 1 })).toEqual([]); + }); + + it("does not accept a __proto__ payload as an object-valued enum member", () => { + const enumSchema = { enum: [{ mode: "safe" }] }; + const proto = JSON.parse('{"__proto__": {}}') as Record; + expect(validateAgainstSchema(enumSchema, proto)).not.toEqual([]); + }); + + it("validates null and boolean types", () => { + expect(validateAgainstSchema({ type: "null" }, null)).toEqual([]); + expect(validateAgainstSchema({ type: "boolean" }, "true")).toEqual([ + { path: "", message: "must be of type boolean, got string" }, + ]); + }); +}); + +describe("applySchemaDefaults", () => { + it("fills missing properties with defaults without mutating the input", () => { + const schema = { + type: "object", + properties: { + limit: { type: "integer", default: 10 }, + query: { type: "string" }, + }, + }; + const input = { query: "x" }; + const result = applySchemaDefaults(schema, input) as Record; + + expect(result).toEqual({ query: "x", limit: 10 }); + expect(input).toEqual({ query: "x" }); + }); + + it("does not override provided values", () => { + const schema = { type: "object", properties: { limit: { default: 10 } } }; + expect(applySchemaDefaults(schema, { limit: 3 })).toEqual({ limit: 3 }); + }); + + it("applies defaults for names inherited from Object.prototype", () => { + const schema = { + type: "object", + properties: { toString: { type: "string", default: "value" } }, + }; + expect(applySchemaDefaults(schema, {})).toEqual({ toString: "value" }); + }); + + it("applies defaults in nested objects and array items", () => { + const schema = { + type: "object", + properties: { + nested: { type: "object", properties: { flag: { default: true } } }, + items: { + type: "array", + items: { type: "object", properties: { size: { default: 1 } } }, + }, + }, + }; + expect(applySchemaDefaults(schema, { nested: {}, items: [{}, { size: 4 }] })).toEqual({ + nested: { flag: true }, + items: [{ size: 1 }, { size: 4 }], + }); + }); + + it("clones object defaults so callers cannot mutate the schema", () => { + const schema = { type: "object", properties: { meta: { default: { a: 1 } } } }; + const first = applySchemaDefaults(schema, {}) as { meta: { a: number } }; + first.meta.a = 99; + const second = applySchemaDefaults(schema, {}) as { meta: { a: number } }; + expect(second.meta.a).toBe(1); + }); +}); + +describe("collectUnsupportedSchemaKeywords", () => { + it("returns an empty list for supported schemas", () => { + expect( + collectUnsupportedSchemaKeywords({ + type: "object", + title: "Input", + description: "annotated", + properties: { query: { type: "string", minLength: 1 } }, + required: ["query"], + additionalProperties: false, + }), + ).toEqual([]); + }); + + it("flags unsupported keywords with schema paths", () => { + expect( + collectUnsupportedSchemaKeywords({ + type: "object", + properties: { + query: { type: "string", pattern: "^a" }, + extra: { oneOf: [{ type: "string" }] }, + }, + }), + ).toEqual(["/properties/query/pattern", "/properties/extra/oneOf"]); + }); + + it("flags unsupported type values and tuple items", () => { + expect(collectUnsupportedSchemaKeywords({ type: ["string", "null"] })).toEqual([ + "/type:", + ]); + expect( + collectUnsupportedSchemaKeywords({ type: "array", items: [{ type: "string" }] }), + ).toEqual(["/items:"]); + }); +}); + +describe("collectInvalidSchemaKeywordValues", () => { + it("rejects malformed supported keyword values recursively", () => { + expect( + collectInvalidSchemaKeywordValues({ + type: 123, + properties: { nested: { required: "id" } }, + additionalProperties: "yes", + }), + ).toEqual([ + "/type:", + "/additionalProperties:", + "/properties/nested/required:", + ]); + }); + + it("rejects non-JSON const, default, and enum values", () => { + expect( + collectInvalidSchemaKeywordValues({ + const: 1n, + default: undefined, + enum: ["ok", new Date()], + }), + ).toEqual([ + "/enum/1:", + "/const:", + "/default:", + ]); + }); +}); diff --git a/packages/capabilities/test/static.test.ts b/packages/capabilities/test/static.test.ts new file mode 100644 index 00000000..6bf54bfc --- /dev/null +++ b/packages/capabilities/test/static.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateLiteral, + extractCapabilityRegistrations, + extractDefineCapabilityArgs, + scanTopLevelProperties, +} from "../src/static.ts"; + +describe("capability static extraction", () => { + it("ignores defineCapability examples in comments and strings", () => { + const source = ` + // defineCapability({ title: "commented out" }) + const example = "defineCapability({ title: 'inside a string' })"; + export default defineCapability({ + title: "Live capability", + run() {}, + }); + `; + + expect(extractDefineCapabilityArgs(source)).toContain('title: "Live capability"'); + }); + + it("extracts the default-exported call, not a preceding helper", () => { + const source = ` + const helper = defineCapability({ title: "Helper", run() {} }); + export default defineCapability({ + title: "Exported", + run() {}, + }); + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).toContain('title: "Exported"'); + expect(args).not.toContain('title: "Helper"'); + }); + + it("resolves an identifier default export to its declaration", () => { + const source = ` + const cap = defineCapability({ title: "Via const", run() {} }); + export default cap; + `; + + expect(extractDefineCapabilityArgs(source)).toContain('title: "Via const"'); + }); + + it("resolves an identifier default export with no trailing semicolon (ASI)", () => { + const source = ` + const cap = defineCapability({ title: "ASI", run() {} }) + export default cap + `; + + expect(extractDefineCapabilityArgs(source)).toContain('title: "ASI"'); + }); + + it("resolves an `export { cap as default }` re-export", () => { + const source = ` + const cap = defineCapability({ title: "As default", run() {} }); + export { cap as default }; + `; + + expect(extractDefineCapabilityArgs(source)).toContain('title: "As default"'); + }); + + it("resolves a declaration with an arrow-function type annotation", () => { + const source = ` + const cap: () => unknown = defineCapability({ title: "Typed", run() {} }); + export default cap; + `; + + expect(extractDefineCapabilityArgs(source)).toContain('title: "Typed"'); + }); + + it("rejects a single call site that is not default-exported", () => { + const source = ` + const cap = defineCapability({ title: "Only call", run() {} }); + `; + + expect(extractDefineCapabilityArgs(source)).toBeNull(); + }); + + it("does not cross an ASI boundary to a later capability declaration", () => { + const source = ` + const cap = factory() + const helper = defineCapability({ title: "Wrong", run() {} }) + export default cap + `; + + expect(extractDefineCapabilityArgs(source)).toBeNull(); + }); + + it("rejects a named-only call when another value is default-exported", () => { + const source = ` + export const helper = defineCapability({ title: "Wrong", run() {} }); + export default {}; + `; + + expect(extractDefineCapabilityArgs(source)).toBeNull(); + }); + + it("resolves the module-scope binding, not a shadowed inner declaration", () => { + const source = ` + function make() { + const cap = defineCapability({ title: "Inner helper", run() {} }); + return cap; + } + const cap = defineCapability({ title: "Module scope", run() {} }); + export default cap; + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).toContain('title: "Module scope"'); + expect(args).not.toContain('title: "Inner helper"'); + }); + + it("does not truncate at a nested template literal in run()", () => { + const source = ` + export default defineCapability({ + title: "Templates", + run({ input }) { + const inner = \`prefix \${\`nested \${input.name}\`} suffix\`; + return { message: inner }; + }, + effect: "read", + expose: { http: true }, + }); + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).toContain('effect: "read"'); + expect(args).toContain("expose:"); + }); + + it("does not truncate at a brace inside a regex literal", () => { + const source = ` + export default defineCapability({ + title: "Regex", + run({ input }) { + return { ok: input.text.match(/[{}]/) !== null }; + }, + effect: "read", + }); + `; + + expect(extractDefineCapabilityArgs(source)).toContain('effect: "read"'); + }); + + it("recognizes regex expression statements after control-flow conditions", () => { + const source = ` + export default defineCapability({ + title: "Conditional regex", + description: "Tests input after a condition.", + input: { type: "object" }, + output: { type: "object" }, + run({ input }) { + if (input.text) /[}]/.test(input.text); + return {}; + }, + effect: "read", + expose: { http: true }, + }); + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).toContain('effect: "read"'); + expect(args).toContain("expose:"); + }); + + it("ignores entry-point lookalikes inside regex literals", () => { + const source = ` + const pattern = /export default defineCapability()/; + const decoy = { effect: "write", expose: { http: true } }; + export default defineCapability({ + title: "Real capability", + description: "The actual default export.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read", + expose: { http: false }, + run() { return {}; }, + }); + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).toContain('title: "Real capability"'); + expect(args).toContain('effect: "read"'); + expect(args).not.toContain('effect: "write"'); + }); + + it("parses Unicode code-point escapes in inline schemas", () => { + const source = String.raw` + export default defineCapability({ + title: "Emoji", + description: "Accept one emoji.", + input: { type: "string", enum: ["\u{1F600}"] }, + output: { type: "string" }, + effect: "read", + expose: { http: true, webmcp: true }, + run() {}, + }); + `; + + const args = extractDefineCapabilityArgs(source); + expect(args).not.toBeNull(); + const input = scanTopLevelProperties(args!).get("input"); + expect(evaluateLiteral(input!)).toEqual({ type: "string", enum: ["😀"] }); + }); + + it("parses code-point escapes with additional leading zeros", () => { + expect(evaluateLiteral(String.raw`"\u{00000001}"`)).toBe("\u0001"); + expect(evaluateLiteral(String.raw`"\u{00010FFFF}"`)).toBe("\u{10ffff}"); + }); + + it("ignores commented-out manifest registrations", () => { + const source = ` + export const app = defineApp({ + capabilities: { + // "notes.old": () => import("./capabilities/notes-old.ts"), + /* "notes.draft": () => import("./capabilities/notes-draft.ts"), */ + "notes.search": () => import("./capabilities/notes-search.ts"), + }, + routes: [], + }); + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "notes.search", file: "./capabilities/notes-search.ts" }, + ]); + }); + + it("extracts registrations from a quoted capabilities property", () => { + const source = ` + const example = '"capabilities": { "notes.fake": "./fake.ts" }'; + export const app = defineApp({ + "capabilities": { + "notes.search": "./capabilities/notes-search.ts", + }, + routes: [], + }); + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "notes.search", file: "./capabilities/notes-search.ts" }, + ]); + }); + + it("scopes registrations to the exported defineApp object", () => { + const source = ` + const metadata = { + capabilities: { + "wrong.tool": () => import("./wrong.ts"), + }, + }; + export const app = defineApp({ + capabilities: { + "right.tool": () => import("./right.ts"), + }, + routes: [], + }); + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "right.tool", file: "./right.ts" }, + ]); + }); + + it("ignores exported-app lookalikes inside regex literals", () => { + const source = ` + const pattern = /export default defineApp()/; + export default defineApp({ + capabilities: { + "right.tool": () => import("./right.ts"), + }, + routes: [], + }); + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "right.tool", file: "./right.ts" }, + ]); + }); + + it("extracts registrations from a typed exported app binding", () => { + const source = ` + export const app: PrachtApp = defineApp({ + capabilities: { + "right.tool": () => import("./right.ts"), + }, + routes: [], + }); + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "right.tool", file: "./right.ts" }, + ]); + }); + + it("extracts registrations from a local binding re-exported as app", () => { + const source = ` + const manifest = defineApp({ + capabilities: { + "right.tool": () => import("./right.ts"), + }, + routes: [], + }); + export { manifest as app }; + `; + + expect(extractCapabilityRegistrations(source)).toEqual([ + { name: "right.tool", file: "./right.ts" }, + ]); + }); +}); diff --git a/packages/capabilities/tsdown.config.ts b/packages/capabilities/tsdown.config.ts new file mode 100644 index 00000000..88d81d17 --- /dev/null +++ b/packages/capabilities/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + entry: ["src/index.ts", "src/static.ts"], + format: "esm", + dts: true, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 050b69be..594f7799 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pracht/capabilities": "workspace:*", "@pracht/core": "workspace:*", "citty": "^0.1.6", "zod": "^4.4.3" diff --git a/packages/cli/src/app-graph.ts b/packages/cli/src/app-graph.ts index 59350830..0b0df442 100644 --- a/packages/cli/src/app-graph.ts +++ b/packages/cli/src/app-graph.ts @@ -1,6 +1,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { resolveRegistryModule, serializeCapabilities } from "@pracht/core"; +import type { AppGraphCapability } from "@pracht/core"; import type { ViteDevServer } from "vite"; import { HTTP_METHODS, type HttpMethod } from "./constants.js"; @@ -32,6 +34,7 @@ export interface AppGraphApiRoute { export interface AppGraph { api: AppGraphApiRoute[]; + capabilities: AppGraphCapability[]; routes: AppGraphRoute[]; /** The app-level not-found page (never part of `routes`), or `null`. */ notFound?: AppGraphRoute | null; @@ -71,11 +74,37 @@ export async function collectAppGraph( const notFound = serverModule.resolvedApp.notFound; return { api: await collectApiRoutes(server, root, serverModule.apiRoutes, options), + capabilities: await serializeCapabilities(serverModule.resolvedApp.capabilities, { + loadModule: capabilityModuleLoader(server, serverModule), + readSource: (file) => readFileSync(resolve(root, `.${file}`), "utf-8"), + }), notFound: notFound ? serializeResolvedRoutes([notFound])[0] : null, routes: serializeResolvedRoutes(serverModule.resolvedApp.routes), }; } +/** + * Manifest capability paths are relative to the app file (e.g. + * `./capabilities/notes-search.ts`), so they only load through the virtual + * server module's registry, which suffix-matches them against its glob keys. + * Fall back to a direct ssrLoadModule for absolute/root-relative paths. + */ +export function capabilityModuleLoader( + server: ViteDevServer, + serverModule: Record, +): (file: string) => Promise> { + const registry = serverModule.registry as + | { capabilityModules?: Record Promise> } + | undefined; + return async (file) => { + const viaRegistry = await resolveRegistryModule>( + registry?.capabilityModules, + file, + ); + return viaRegistry ?? server.ssrLoadModule(file); + }; +} + export function serializeResolvedRoutes(routes: ResolvedRouteEntry[]): AppGraphRoute[] { return routes.map((route) => ({ file: route.file, diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 65646872..41684c5a 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -204,6 +204,14 @@ export async function runBuild(root: string, options: BuildOptions = {}): Promis } } + // The server module only exports generateLlmsTxt when the vite plugin's + // `llmsTxt` option is enabled — disabled builds skip this entirely. + if (typeof serverMod.generateLlmsTxt === "function") { + const llmsTxt: string = await serverMod.generateLlmsTxt(); + writeFileSync(resolve(clientDir, "llms.txt"), llmsTxt, "utf-8"); + log("\n llms.txt → dist/client/llms.txt\n"); + } + if (Object.keys(headersManifest).length > 0) { const headersManifestJson = `${JSON.stringify(headersManifest, null, 2)}\n`; writeFileSync( diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d41bc4dd..17836316 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -7,7 +7,13 @@ import { createServer, type ViteDevServer } from "vite"; import { collectAppGraph } from "../app-graph.js"; import { formatDevBanner, supportsColor } from "../dev-banner.js"; import { readProjectConfig, resolveProjectPath } from "../project.js"; -import { DEFAULT_DECLARATION_OUT, DEFAULT_RUNTIME_OUT, runTypegen } from "./typegen.js"; +import { requirePositiveInteger } from "../utils.js"; +import { + DEFAULT_CAPABILITIES_OUT, + DEFAULT_DECLARATION_OUT, + DEFAULT_RUNTIME_OUT, + runTypegen, +} from "./typegen.js"; export default defineCommand({ meta: { @@ -16,13 +22,18 @@ export default defineCommand({ }, args: { port: { - type: "positional", - description: "Port number", - required: false, + type: "string", + description: "Port number (defaults to $PORT or 3000)", }, }, async run({ args }) { - const port = parseInt(process.env.PORT || args.port || "3000", 10); + // `pracht dev 4000` (legacy positional) still works alongside `--port`. + const positionalPort = args._?.[0] != null ? String(args._[0]) : undefined; + const port = requirePositiveInteger( + args.port ?? positionalPort ?? process.env.PORT, + "port", + 3000, + ); const root = process.cwd(); const server = await createServer({ @@ -39,6 +50,7 @@ export default defineCommand({ console.log( formatDevBanner({ apiRoutes: graph.api, + capabilities: graph.capabilities, color: supportsColor(), localUrls: urls.local, networkUrls: urls.network, @@ -80,7 +92,11 @@ function watchGeneratedRouteTypes(server: ViteDevServer, root: string): boolean return false; } - const generatedPaths = new Set([declarationPath, resolve(root, DEFAULT_RUNTIME_OUT)]); + const generatedPaths = new Set([ + declarationPath, + resolve(root, DEFAULT_RUNTIME_OUT), + resolve(root, DEFAULT_CAPABILITIES_OUT), + ]); const appFilePath = resolveProjectPath(root, readProjectConfig(root).appFile); let queued: ReturnType | null = null; let running = false; @@ -94,6 +110,7 @@ function watchGeneratedRouteTypes(server: ViteDevServer, root: string): boolean running = true; try { await runTypegen({ + capabilitiesOut: DEFAULT_CAPABILITIES_OUT, check: false, declarationOut: DEFAULT_DECLARATION_OUT, root, diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts new file mode 100644 index 00000000..2a558a43 --- /dev/null +++ b/packages/cli/src/commands/eval.ts @@ -0,0 +1,233 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { relative } from "node:path"; + +import { defineCommand } from "citty"; + +import { + findEvalFiles, + parseScenario, + runScenario, + waitForServer, + type EvalScenarioResult, +} from "../eval-runner.js"; + +const DEFAULT_START_URL = "http://localhost:3000"; + +export default defineCommand({ + meta: { + name: "eval", + description: "Run scripted agent-task scenarios against the capability HTTP projection", + }, + args: { + files: { + type: "positional", + description: "Scenario files (defaults to evals/**/*.eval.json)", + required: false, + }, + url: { + type: "string", + description: "Base URL of the running app (overrides per-file url)", + }, + start: { + type: "string", + description: + 'Command that starts your app (e.g. "pracht preview"). pracht eval launches it, ' + + `waits for a response at --url (default ${DEFAULT_START_URL}), runs the scenarios, ` + + "then stops it", + }, + json: { + type: "boolean", + description: "Output as JSON", + }, + }, + async run({ args }) { + const cwd = process.cwd(); + const explicit = (args._ ?? []).map(String); + const files = findEvalFiles(cwd, explicit); + + if (files.length === 0) { + console.error( + explicit.length > 0 + ? "No scenario files matched." + : "No evals/**/*.eval.json scenario files found. Pass files explicitly: pracht eval ", + ); + process.exitCode = 1; + return; + } + + let urlOverride = args.url ? String(args.url) : undefined; + let child: ChildProcess | undefined; + let signalHandler: ((signal: NodeJS.Signals) => void) | undefined; + + const releaseSignalHandler = (): void => { + if (!signalHandler) return; + process.removeListener("SIGINT", signalHandler); + process.removeListener("SIGTERM", signalHandler); + signalHandler = undefined; + }; + + if (args.start) { + const startCommand = String(args.start); + // One started server serves every scenario, so its URL overrides + // per-file urls too. + const baseUrl = urlOverride ?? DEFAULT_START_URL; + urlOverride = baseUrl; + + let output = ""; + let exitReason: string | null = null; + child = spawn(startCommand, { + shell: true, + // Its own process group on POSIX, so stopping it also stops whatever + // the shell command spawned (package managers, dev servers). + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on("exit", (code) => { + exitReason = `the start command exited with code ${code ?? "unknown"} before the server answered`; + }); + + // A detached child (its own process group) does not receive the + // terminal's Ctrl+C, so stop it explicitly before exiting — otherwise it + // orphans and keeps holding its port. + signalHandler = (signal) => { + stopStartedCommand(child!); + process.exit(signal === "SIGINT" ? 130 : 143); + }; + process.once("SIGINT", signalHandler); + process.once("SIGTERM", signalHandler); + + if (!args.json) { + console.log(`Starting app: ${startCommand}`); + console.log(`Waiting for ${baseUrl} ...`); + } + const ready = await waitForServer(baseUrl, { earlyExit: () => exitReason }); + if (!ready.ok) { + releaseSignalHandler(); + stopStartedCommand(child); + console.error(`Could not reach the app at ${baseUrl}: ${ready.reason}`); + if (output.trim() !== "") { + console.error(`\n--- start command output ---\n${output.trimEnd()}`); + } + process.exitCode = 1; + return; + } + } + + try { + const results: EvalScenarioResult[] = []; + for (const file of files) { + results.push(await runEvalFile(file, cwd, urlOverride)); + } + + const ok = results.every((result) => result.ok && result.error === null); + if (args.json) { + console.log(JSON.stringify({ ok, scenarios: results }, null, 2)); + } else { + printTranscript(results, cwd); + } + if (!ok) { + process.exitCode = 1; + } + } finally { + releaseSignalHandler(); + if (child) { + stopStartedCommand(child); + } + } + }, +}); + +/** Stop the `--start` process — the whole group on POSIX (`shell: true` spawns children). */ +function stopStartedCommand(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, "SIGTERM"); + return; + } catch { + // Group already gone — fall through to the direct kill. + } + } + if (process.platform === "win32" && child.pid) { + // `shell: true` spawns a cmd.exe; SIGTERM only kills that shell, leaving + // the actual server (a descendant) running. taskkill /T ends the tree. + try { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // Fall through to the direct kill. + } + } + child.kill("SIGTERM"); +} + +async function runEvalFile( + file: string, + cwd: string, + urlOverride: string | undefined, +): Promise { + let scenario; + try { + scenario = parseScenario(file); + } catch (error: unknown) { + return { + name: relative(cwd, file), + file, + ok: false, + steps: [], + error: `could not load scenario: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const baseUrl = urlOverride ?? scenario.url; + if (!baseUrl) { + return { + name: scenario.name, + file, + ok: false, + steps: [], + error: + 'no target server: pass --url or set "url" in the scenario file. ' + + "Tip: run `pracht preview` (or `pracht dev`) in another terminal and point --url at it, " + + 'or let pracht eval manage the server with --start "pracht preview".', + }; + } + + return runScenario(scenario, file, { baseUrl }); +} + +function printTranscript(results: EvalScenarioResult[], cwd: string): void { + let passed = 0; + let failed = 0; + + for (const result of results) { + const marker = result.ok && result.error === null ? "PASS" : "FAIL"; + console.log(`\n${marker} ${result.name} (${relative(cwd, result.file)})`); + if (result.error) { + console.log(` ${result.error}`); + } + for (const [index, step] of result.steps.entries()) { + const outcome = step.ok ? "ok" : (step.errorCode ?? `status ${step.status}`); + const stepMarker = step.failures.length === 0 ? "✓" : "✗"; + console.log( + ` ${stepMarker} ${index + 1}. ${step.capability} → ${outcome} ` + + `(${step.status}, ${step.latencyMs.toFixed(0)}ms)`, + ); + for (const failure of step.failures) { + console.log(` ${failure}`); + } + } + if (result.ok && result.error === null) passed += 1; + else failed += 1; + } + + console.log(`\n${passed} scenario(s) passed, ${failed} failed.`); +} diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index 89b3f920..ac94db6c 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -1,15 +1,21 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { serializeApiRoutes, serializeAppRoutes } from "@pracht/core"; -import type { AppGraphApiRoute, AppGraphRoute, ResolvedApiRoute } from "@pracht/core"; +import { serializeApiRoutes, serializeAppRoutes, serializeCapabilities } from "@pracht/core"; +import type { + AppGraphApiRoute, + AppGraphCapability, + AppGraphRoute, + ResolvedApiRoute, +} from "@pracht/core"; import { defineCommand } from "citty"; +import { capabilityModuleLoader } from "../app-graph.js"; import { withAppServer } from "../app-server.js"; import { handleCliError } from "../utils.js"; import { readClientBuildAssets } from "../build-metadata.js"; -const INSPECT_TARGETS = new Set(["routes", "api", "build", "all"]); +const INSPECT_TARGETS = new Set(["routes", "api", "capabilities", "build", "all"]); export default defineCommand({ meta: { @@ -19,7 +25,7 @@ export default defineCommand({ args: { target: { type: "positional", - description: "Inspect target: routes, api, build, or all", + description: "Inspect target: routes, api, capabilities, build, or all", required: false, }, json: { @@ -49,6 +55,7 @@ export default defineCommand({ export interface InspectReport { api?: AppGraphApiRoute[]; + capabilities?: AppGraphCapability[]; build?: { adapterTarget: string; clientEntryUrl: string | null; @@ -62,20 +69,26 @@ export interface InspectReport { export async function runInspect( root: string, - { inspectApiMethods = true, target = "all" } = {}, + { + inspectApiMethods = true, + target = "all", + }: { inspectApiMethods?: boolean; target?: string | string[] } = {}, ): Promise { + const targets = new Set(Array.isArray(target) ? target : [target]); + const wants = (name: string) => targets.has(name) || targets.has("all"); + return withAppServer(root, async ({ project, server, serverModule }) => { const report: InspectReport = { mode: project.mode, }; - if (target === "routes" || target === "all") { + if (wants("routes")) { report.routes = serializeAppRoutes(serverModule.resolvedApp.routes); const notFound = serverModule.resolvedApp.notFound; report.notFound = notFound ? serializeAppRoutes([notFound])[0] : null; } - if (target === "api" || target === "all") { + if (wants("api")) { report.api = inspectApiMethods ? await serializeApiRoutes(serverModule.apiRoutes, { loadModule: (file) => server.ssrLoadModule(file), @@ -89,7 +102,14 @@ export async function runInspect( })); } - if (target === "build" || target === "all") { + if (wants("capabilities")) { + report.capabilities = await serializeCapabilities(serverModule.resolvedApp.capabilities, { + loadModule: capabilityModuleLoader(server, serverModule), + readSource: (file) => readFileSync(resolve(root, `.${file}`), "utf-8"), + }); + } + + if (wants("build")) { const buildAssets = readClientBuildAssets(root); report.build = { adapterTarget: serverModule.buildTarget, @@ -139,6 +159,22 @@ function printInspectReport(report: InspectReport): void { } } + if (report.capabilities) { + console.log("\nCapabilities"); + if (report.capabilities.length === 0) { + console.log(" No capabilities registered."); + } else { + for (const capability of report.capabilities) { + const transports = + capability.transports.length > 0 ? capability.transports.join(",") : "private"; + console.log( + ` ${capability.name} effect=${capability.effect ?? "n/a"} transports=${transports} ` + + `http=${capability.httpPath ?? "n/a"} file=${capability.source}`, + ); + } + } + } + if (report.build) { console.log("\nBuild"); console.log(` adapterTarget=${report.build.adapterTarget}`); diff --git a/packages/cli/src/commands/typegen.ts b/packages/cli/src/commands/typegen.ts index 93cc4ec6..834c64a9 100644 --- a/packages/cli/src/commands/typegen.ts +++ b/packages/cli/src/commands/typegen.ts @@ -4,6 +4,7 @@ import { dirname, isAbsolute, relative, resolve } from "node:path"; import { defineCommand } from "citty"; import { displayPath, readProjectConfig, resolveProjectPath } from "../project.js"; +import { schemaToTypeText } from "@pracht/capabilities"; import { ensureTrailingNewline, handleCliError } from "../utils.js"; import { runInspect, type InspectReport } from "./inspect.js"; @@ -13,10 +14,12 @@ import { runInspect, type InspectReport } from "./inspect.js"; // the program, so its `Register` augmentation never applies. export const DEFAULT_DECLARATION_OUT = "src/pracht.d.ts"; export const DEFAULT_RUNTIME_OUT = "src/pracht-routes.ts"; +export const DEFAULT_CAPABILITIES_OUT = "src/pracht-capabilities.d.ts"; const LEGACY_DECLARATION_OUT = "src/pracht-routes.d.ts"; type RouteEntry = NonNullable[number]; type ApiRouteEntry = NonNullable[number]; +type CapabilityEntry = NonNullable[number]; export default defineCommand({ meta: { @@ -32,6 +35,10 @@ export default defineCommand({ type: "string", description: `Runtime href helper output path (default: ${DEFAULT_RUNTIME_OUT})`, }, + "capabilities-out": { + type: "string", + description: `Capability declaration output path (default: ${DEFAULT_CAPABILITIES_OUT})`, + }, check: { type: "boolean", description: "Check whether generated route files are up to date without writing", @@ -45,6 +52,10 @@ export default defineCommand({ const json = Boolean(args.json); try { const result = await runTypegen({ + capabilitiesOut: + typeof args["capabilities-out"] === "string" + ? args["capabilities-out"] + : DEFAULT_CAPABILITIES_OUT, check: Boolean(args.check), declarationOut: typeof args.out === "string" ? args.out : DEFAULT_DECLARATION_OUT, root: process.cwd(), @@ -73,6 +84,7 @@ export default defineCommand({ }); export interface TypegenOptions { + capabilitiesOut: string; check: boolean; declarationOut: string; root: string; @@ -81,6 +93,7 @@ export interface TypegenOptions { interface TypegenResult { apiRoutes: number; + capabilities: number; check: boolean; files: string[]; mode: string; @@ -91,15 +104,20 @@ export async function runTypegen(options: TypegenOptions): Promise 0 || existsSync(capabilitiesPath)) { + outputs.push({ + path: capabilitiesPath, + source: buildCapabilityDeclarationSource(capabilities), + }); + } + if (options.check) { const stale = outputs.filter((output) => !fileMatches(output.path, output.source)); if (stale.length > 0) { @@ -143,6 +189,7 @@ export async function runTypegen(options: TypegenOptions): Promise displayPath(options.root, output.path)), mode: report.mode, @@ -263,6 +310,45 @@ function buildDeclarationSource( return lines.join("\n"); } +/** + * Register capability input/output types on `Register["capabilities"]`, the + * capability counterpart of the route declaration file. `invokeCapability()`, + * `callCapability()`, and the capability test host infer their input and + * output types from the capability name once this file is in the program. + */ +function buildCapabilityDeclarationSource(capabilities: CapabilityEntry[]): string { + const lines = [ + "// Generated by `pracht typegen`. Do not edit manually.", + 'import "@pracht/core";', + "", + 'declare module "@pracht/core" {', + " interface Register {", + ]; + + if (capabilities.length === 0) { + lines.push(" capabilities: Record;"); + } else { + lines.push(" capabilities: {"); + for (const capability of capabilities) { + // Broken registrations (module failed to load) fall back to `unknown` + // schemas here; `pracht verify` and `pracht doctor` report the wiring. + lines.push(` ${JSON.stringify(capability.name)}: {`); + lines.push(` input: ${schemaToTypeText(capability.input, "input")};`); + lines.push(` output: ${schemaToTypeText(capability.output, "output")};`); + lines.push(" };"); + } + lines.push(" };"); + } + + lines.push(" }"); + lines.push("}"); + lines.push(""); + lines.push("export {};"); + lines.push(""); + + return lines.join("\n"); +} + function buildRuntimeSource(routes: RouteEntry[]): string { const lines = [ "// Generated by `pracht typegen`. Do not edit manually.", diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 5b2bf11c..30659a3b 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -19,6 +19,7 @@ function readPackageVersion(): string { export const PROJECT_DEFAULTS = { apiDir: "/src/api", appFile: "/src/routes.ts", + capabilitiesDir: "/src/capabilities", middlewareDir: "/src/middleware", pagesDefaultRender: "ssr", pagesDir: "", diff --git a/packages/cli/src/dev-banner.ts b/packages/cli/src/dev-banner.ts index d39fdf91..2ff5c63f 100644 --- a/packages/cli/src/dev-banner.ts +++ b/packages/cli/src/dev-banner.ts @@ -1,3 +1,5 @@ +import type { AppGraphCapability } from "@pracht/core"; + import type { AppGraphApiRoute, AppGraphRoute } from "./app-graph.js"; export interface DevBannerRoute extends Pick< @@ -7,8 +9,14 @@ export interface DevBannerRoute extends Pick< export interface DevBannerApiRoute extends Pick {} +export interface DevBannerCapability extends Pick< + AppGraphCapability, + "effect" | "httpPath" | "name" | "transports" +> {} + export interface DevBannerOptions { apiRoutes: DevBannerApiRoute[]; + capabilities?: DevBannerCapability[]; color?: boolean; localUrls: string[]; networkUrls?: string[]; @@ -22,6 +30,7 @@ const ANSI = { dim: "2", green: "32", magenta: "35", + red: "31", yellow: "33", }; @@ -32,12 +41,26 @@ const MODE_COLORS: Record = { ssr: ANSI.yellow, }; +const EFFECT_COLORS: Record = { + destructive: ANSI.red, + read: ANSI.green, + write: ANSI.yellow, +}; + /** * Format the `pracht dev` startup banner: local URL(s) plus an aligned table * of page routes (pattern, render mode, shell, middleware) and API routes. */ export function formatDevBanner(options: DevBannerOptions): string { - const { apiRoutes, color = false, localUrls, networkUrls = [], notFound, routes } = options; + const { + apiRoutes, + capabilities = [], + color = false, + localUrls, + networkUrls = [], + notFound, + routes, + } = options; const paint = (text: string, code: string): string => color ? `\u001b[${code}m${text}\u001b[0m` : text; @@ -99,6 +122,38 @@ export function formatDevBanner(options: DevBannerOptions): string { } lines.push(""); + // Apps without capabilities skip the section entirely — most apps don't + // register any, and an empty table would only add noise. + if (capabilities.length > 0) { + lines.push(` ${paint(`Capabilities (${capabilities.length})`, ANSI.bold)}`); + const rows = capabilities.map((capability) => [ + capability.name, + capability.effect ?? "?", + capability.transports.length > 0 + ? capability.transports + // Remote MCP is recorded in the graph but not served yet + // (capability-graph Stage 2) — don't let the banner imply it is. + .map((transport) => (transport === "mcp" ? "mcp(unserved)" : transport)) + .join(",") + : "private", + capability.httpPath ?? "-", + ]); + const header = ["NAME", "EFFECT", "EXPOSURE", "HTTP"]; + const widths = columnWidths([header, ...rows]); + lines.push(` ${paint(formatRow(header, widths), ANSI.dim)}`); + for (const row of rows) { + const [name, effect, exposure, httpPath] = row; + const cells = [ + name.padEnd(widths[0]), + paint(effect.padEnd(widths[1]), EFFECT_COLORS[effect] ?? ANSI.dim), + exposure.padEnd(widths[2]), + httpPath, + ]; + lines.push(` ${cells.join(" ")}`.trimEnd()); + } + lines.push(""); + } + return lines.join("\n"); } diff --git a/packages/cli/src/eval-runner.ts b/packages/cli/src/eval-runner.ts new file mode 100644 index 00000000..5a6dfa8c --- /dev/null +++ b/packages/cli/src/eval-runner.ts @@ -0,0 +1,369 @@ +/** + * `pracht eval` — scripted agent-task harness. + * + * Runs JSON scenario files against a live app's capability HTTP projection + * and checks each step's outcome, turning the capability graph's proof + * metrics ("can an agent actually complete this task through my tools?") + * into repeatable CI checks. Scenario format (docs/AGENT_TRUST.md): + * + * { + * "name": "notes flow", + * "task": "search, then purge with confirmation", + * "url": "http://localhost:3000", // optional; --url overrides + * "steps": [ + * { + * "capability": "notes.search", // or "path": "/api/custom" + * "input": { "query": "roadmap" }, + * "confirm": "$steps[0].error.confirmationToken", // sets the confirmation header + * "expect": { "ok": true, "errorCode": "...", "status": 200, + * "output": { "notes": [] } } // subset match + * } + * ] + * } + * + * Reference syntax: a string value that is exactly `$steps[].` + * is replaced with that value from an earlier step's result. The root object + * per step is `{ status, ok, data, error }` — e.g. + * `$steps[0].error.confirmationToken` or `$steps[1].data.note.id`. + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { capabilityHttpPath, CONFIRMATION_HEADER } from "@pracht/capabilities"; + +export interface EvalExpectation { + ok?: boolean; + errorCode?: string; + status?: number; + /** Deep subset match against the envelope's `data`. */ + output?: unknown; +} + +export interface EvalStep { + capability: string; + /** Custom HTTP path override (for `expose.http.path` capabilities). */ + path?: string; + input?: unknown; + headers?: Record; + /** + * Confirmation token for committing a destructive capability — usually a + * `$steps[n].error.confirmationToken` reference. Sets the confirmation + * header without spelling out the header name. + */ + confirm?: string; + expect?: EvalExpectation; +} + +export interface EvalScenario { + name: string; + task?: string; + url?: string; + steps: EvalStep[]; +} + +export interface EvalStepResult { + capability: string; + status: number; + ok: boolean; + latencyMs: number; + /** Envelope error code when the step failed at the capability layer. */ + errorCode: string | null; + /** Expectation failures; empty when the step passed. */ + failures: string[]; + /** Parsed envelope + status, used for `$steps[n]` references. */ + resultForReferences: Record; +} + +export interface EvalScenarioResult { + name: string; + file: string; + ok: boolean; + steps: EvalStepResult[]; + /** Scenario-level failure (bad file, no URL, network error). */ + error: string | null; +} + +export { capabilityHttpPath }; + +// --------------------------------------------------------------------------- +// Scenario discovery and parsing +// --------------------------------------------------------------------------- + +/** + * Resolve scenario files: explicit paths as-is, otherwise every + * `*.eval.json` under `evals/` (recursively). + */ +export function findEvalFiles(cwd: string, explicit: string[]): string[] { + if (explicit.length > 0) { + return explicit.map((file) => resolve(cwd, file)); + } + const files: string[] = []; + walkForEvalFiles(resolve(cwd, "evals"), files); + return files.sort(); +} + +function walkForEvalFiles(dir: string, files: string[]): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + const full = join(dir, entry); + let stats; + try { + stats = statSync(full); + } catch { + continue; + } + if (stats.isDirectory()) { + walkForEvalFiles(full, files); + } else if (entry.endsWith(".eval.json")) { + files.push(full); + } + } +} + +export function parseScenario(file: string): EvalScenario { + const parsed: unknown = JSON.parse(readFileSync(file, "utf-8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("scenario must be a JSON object"); + } + const scenario = parsed as Partial; + if (typeof scenario.name !== "string" || scenario.name === "") { + throw new Error('scenario is missing a "name"'); + } + if (!Array.isArray(scenario.steps) || scenario.steps.length === 0) { + throw new Error('scenario needs a non-empty "steps" array'); + } + for (const [index, step] of scenario.steps.entries()) { + if (!step || typeof step !== "object" || typeof step.capability !== "string") { + throw new Error(`step ${index} is missing a "capability" name`); + } + } + return scenario as EvalScenario; +} + +// --------------------------------------------------------------------------- +// Reference substitution +// --------------------------------------------------------------------------- + +const REFERENCE_RE = /^\$steps\[(\d+)\]\.(.+)$/; + +/** + * Replace `$steps[n].` string values (in inputs/headers) with values + * from earlier step results. Unknown indices or paths throw — a scenario + * referencing a value that does not exist is a scenario bug. + */ +export function resolveStepReferences(value: unknown, prior: EvalStepResult[]): unknown { + if (typeof value === "string") { + const match = REFERENCE_RE.exec(value); + if (!match) return value; + const index = Number(match[1]); + if (index >= prior.length) { + throw new Error(`reference "${value}" points at step ${index}, which has not run yet`); + } + let current: unknown = prior[index].resultForReferences; + for (const segment of match[2].split(".")) { + if (!current || typeof current !== "object") { + throw new Error(`reference "${value}" found nothing at "${segment}"`); + } + current = (current as Record)[segment]; + } + if (current === undefined) { + throw new Error(`reference "${value}" resolved to undefined`); + } + return current; + } + if (Array.isArray(value)) { + return value.map((item) => resolveStepReferences(item, prior)); + } + if (value && typeof value === "object") { + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = resolveStepReferences(entry, prior); + } + return result; + } + return value; +} + +// --------------------------------------------------------------------------- +// Expectation matching +// --------------------------------------------------------------------------- + +/** Deep subset match: every property in `expected` must equal/subset-match `actual`. */ +export function matchesSubset(actual: unknown, expected: unknown): boolean { + if (expected === null || typeof expected !== "object") { + return actual === expected; + } + if (Array.isArray(expected)) { + if (!Array.isArray(actual) || actual.length !== expected.length) return false; + return expected.every((item, index) => matchesSubset(actual[index], item)); + } + if (!actual || typeof actual !== "object" || Array.isArray(actual)) return false; + return Object.entries(expected as Record).every(([key, value]) => + matchesSubset((actual as Record)[key], value), + ); +} + +export function collectExpectationFailures( + expect: EvalExpectation | undefined, + status: number, + envelope: { ok?: unknown; data?: unknown; error?: { code?: unknown } }, +): string[] { + const failures: string[] = []; + if (!expect) { + // No expectation: the step must simply succeed. + if (envelope.ok !== true) { + failures.push( + `expected ok envelope, got ${String(envelope.error?.code ?? "ok=" + String(envelope.ok))} (status ${status})`, + ); + } + return failures; + } + if (expect.ok !== undefined && envelope.ok !== expect.ok) { + failures.push(`expected ok=${expect.ok}, got ok=${String(envelope.ok)}`); + } + if (expect.status !== undefined && status !== expect.status) { + failures.push(`expected status ${expect.status}, got ${status}`); + } + if (expect.errorCode !== undefined && envelope.error?.code !== expect.errorCode) { + failures.push( + `expected error code "${expect.errorCode}", got ${JSON.stringify(envelope.error?.code ?? null)}`, + ); + } + if (expect.output !== undefined && !matchesSubset(envelope.data, expect.output)) { + failures.push(`output does not match expected subset ${JSON.stringify(expect.output)}`); + } + return failures; +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +export interface RunScenarioOptions { + baseUrl: string; + fetchImpl?: typeof fetch; +} + +export async function runScenario( + scenario: EvalScenario, + file: string, + options: RunScenarioOptions, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const steps: EvalStepResult[] = []; + + for (const step of scenario.steps) { + let input: unknown; + let headers: Record; + try { + input = resolveStepReferences(step.input === undefined ? {} : step.input, steps); + headers = resolveStepReferences(step.headers ?? {}, steps) as Record; + if (step.confirm !== undefined) { + headers[CONFIRMATION_HEADER] = String(resolveStepReferences(step.confirm, steps)); + } + } catch (error: unknown) { + return { + name: scenario.name, + file, + ok: false, + steps, + error: error instanceof Error ? error.message : String(error), + }; + } + + const path = step.path ?? capabilityHttpPath(step.capability); + const url = new URL(path, options.baseUrl).toString(); + const started = performance.now(); + let status: number; + let envelope: { ok?: unknown; data?: unknown; error?: { code?: unknown } }; + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(input), + }); + status = response.status; + envelope = (await response.json()) as typeof envelope; + } catch (error: unknown) { + return { + name: scenario.name, + file, + ok: false, + steps, + error: `request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + const latencyMs = performance.now() - started; + + const failures = collectExpectationFailures(step.expect, status, envelope); + steps.push({ + capability: step.capability, + status, + ok: envelope.ok === true, + latencyMs, + errorCode: + envelope.ok === true + ? null + : typeof envelope.error?.code === "string" + ? envelope.error.code + : null, + failures, + resultForReferences: { status, ...envelope } as Record, + }); + } + + return { + name: scenario.name, + file, + ok: steps.every((step) => step.failures.length === 0), + steps, + error: null, + }; +} + +// --------------------------------------------------------------------------- +// `--start` support: wait for a just-spawned app server to answer +// --------------------------------------------------------------------------- + +export interface WaitForServerOptions { + timeoutMs?: number; + intervalMs?: number; + /** Checked between attempts — return a reason to abort early (e.g. the started process already exited). */ + earlyExit?: () => string | null; + fetchImpl?: typeof fetch; +} + +export type WaitForServerResult = { ok: true } | { ok: false; reason: string }; + +/** + * Poll a base URL until the server answers. Any HTTP response counts as + * ready — 404s included — because reachability is all the scenario runner + * needs before it starts dispatching capability calls. + */ +export async function waitForServer( + baseUrl: string, + options: WaitForServerOptions = {}, +): Promise { + const { timeoutMs = 30_000, intervalMs = 250, earlyExit, fetchImpl = fetch } = options; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const abortReason = earlyExit?.(); + if (abortReason) { + return { ok: false, reason: abortReason }; + } + try { + await fetchImpl(baseUrl, { signal: AbortSignal.timeout(2_000) }); + return { ok: true }; + } catch { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + return { ok: false, reason: `no response from ${baseUrl} within ${timeoutMs}ms` }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dabaca0c..f129b488 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -17,6 +17,7 @@ const main = defineCommand({ build: () => import("./commands/build.js").then((m) => m.default), dev: () => import("./commands/dev.js").then((m) => m.default), doctor: () => import("./commands/doctor.js").then((m) => m.default), + eval: () => import("./commands/eval.js").then((m) => m.default), generate: () => import("./commands/generate.js").then((m) => m.default), inspect: () => import("./commands/inspect.js").then((m) => m.default), llms: () => import("./commands/llms.js").then((m) => m.default), diff --git a/packages/cli/src/manifest.ts b/packages/cli/src/manifest.ts index 5e47eadb..e771f07c 100644 --- a/packages/cli/src/manifest.ts +++ b/packages/cli/src/manifest.ts @@ -1,3 +1,5 @@ +import { extractDefineAppObjectBody, scanTopLevelProperties } from "@pracht/capabilities/static"; + export function ensureCoreNamedImport(source: string, name: string): string { const match = source.match(/import\s*\{([^}]+)\}\s*from\s*["']@pracht\/core["'];?/); if (!match) { @@ -49,19 +51,84 @@ export function toManifestModulePath(manifestPath: string, targetFilePath: strin return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } +/** + * Replace `//` line and block comments with spaces, leaving string/template + * contents untouched so a `//` inside a path is not mistaken for a comment. + * Length is preserved so callers can still slice by original offsets. + */ +function maskComments(source: string): string { + let result = ""; + let index = 0; + while (index < source.length) { + const char = source[index]; + if (char === '"' || char === "'" || char === "`") { + const quote = char; + result += char; + index += 1; + while (index < source.length) { + const inner = source[index]; + result += inner; + index += 1; + if (inner === "\\") { + if (index < source.length) { + result += source[index]; + index += 1; + } + continue; + } + if (inner === quote) break; + } + continue; + } + if (char === "/" && source[index + 1] === "/") { + while (index < source.length && source[index] !== "\n") { + result += " "; + index += 1; + } + continue; + } + if (char === "/" && source[index + 1] === "*") { + while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) { + result += source[index] === "\n" ? "\n" : " "; + index += 1; + } + if (index < source.length) { + result += " "; + index += 2; + } + continue; + } + result += char; + index += 1; + } + return result; +} + export function extractRegistryEntries( source: string, key: string, ): { name: string; path: string }[] { - const block = findNamedBlock(source, key, "{", "}"); - if (!block) return []; - const inner = source.slice(block.openIndex + 1, block.closeIndex); + // Mask comments BEFORE locating the block so a block-commented example + // (`/* capabilities: { ... } */`) cannot be selected instead of the live + // registry, and commented-out registrations inside the live block are not + // treated as registered (mirrors the analyzer in @pracht/capabilities). + // Masking preserves offsets, so slicing the masked source is safe. + const appBody = extractDefineAppObjectBody(source); + if (!appBody) return []; + const value = scanTopLevelProperties(appBody).get(key); + if (!value) return []; + const openIndex = value.search(/\S/); + if (openIndex === -1 || value[openIndex] !== "{") return []; + const closeIndex = findMatchingDelimiter(value, openIndex, "{", "}"); + const inner = maskComments(value.slice(openIndex + 1, closeIndex)); const entries: { name: string; path: string }[] = []; + // Keys may be bare identifiers (shells, middleware) or quoted strings — + // capability names like "notes.search" require quoting. const pattern = - /([A-Za-z0-9_-]+)\s*:\s*(?:(["'`])([^"'`]+)\2|\(\)\s*=>\s*import\(\s*(["'`])([^"'`]+)\4\s*\))/g; + /(?:(["'])([^"'\n]+)\1|([A-Za-z0-9_-]+))\s*:\s*(?:(["'`])([^"'`]+)\4|\(\)\s*=>\s*import\(\s*(["'`])([^"'`]+)\6\s*\))/g; for (const match of inner.matchAll(pattern)) { - entries.push({ name: match[1], path: match[3] ?? match[5] }); + entries.push({ name: match[2] ?? match[3], path: match[5] ?? match[7] }); } return entries; diff --git a/packages/cli/src/mcp-server.ts b/packages/cli/src/mcp-server.ts index 851c863e..c69f1950 100644 --- a/packages/cli/src/mcp-server.ts +++ b/packages/cli/src/mcp-server.ts @@ -51,6 +51,16 @@ export function createPrachtMcpServer(): McpServer { guard(({ cwd }) => runInspect(resolveCwd(cwd), { target: "api" })), ); + server.registerTool( + "inspect_capabilities", + { + description: + "Inspect the registered capabilities of a pracht app: name, effect class, exposure transports (http/mcp/webmcp), HTTP path, middleware, source file. Same payload as `pracht inspect capabilities --json`.", + inputSchema: { ...cwdInput }, + }, + guard(({ cwd }) => runInspect(resolveCwd(cwd), { target: "capabilities" })), + ); + server.registerTool( "inspect_build", { diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts index 4027653c..bb41a424 100644 --- a/packages/cli/src/project.ts +++ b/packages/cli/src/project.ts @@ -7,6 +7,7 @@ import { PROJECT_DEFAULTS } from "./constants.js"; export interface ProjectConfig { apiDir: string; appFile: string; + capabilitiesDir: string; configFile: string | null; hasPrachtPlugin: boolean; middlewareDir: string; diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index f88ed2b2..5297097e 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -51,7 +51,9 @@ export function requirePositiveInteger( key: string, fallback: number, ): number { - const parsed = value == null ? fallback : Number.parseInt(value, 10); + // Treat an unset OR empty env var (`PORT=`) as absent so it falls back + // instead of parsing to NaN and throwing. + const parsed = value == null || value === "" ? fallback : Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error(`--${key} must be a positive integer.`); } diff --git a/packages/cli/src/verification-capabilities.ts b/packages/cli/src/verification-capabilities.ts new file mode 100644 index 00000000..893bffd0 --- /dev/null +++ b/packages/cli/src/verification-capabilities.ts @@ -0,0 +1,367 @@ +import { dirname, resolve } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +import { + collectInvalidSchemaKeywordValues, + collectUnsupportedSchemaKeywords, + isValidCapabilityHttpPath, +} from "@pracht/capabilities"; +import { + evaluateLiteral, + extractCapabilityRegistrations, + extractDefineCapabilityArgs, + scanTopLevelProperties, +} from "@pracht/capabilities/static"; + +import { extractRegistryEntries } from "./manifest.js"; +import { resolveProjectPath, type ProjectConfig } from "./project.js"; +import { createCheck, type Check } from "./verification-helpers.js"; + +const CAPABILITY_EFFECTS = new Set(["read", "write", "destructive"]); +const AGENT_POLICIES = new Set(["observe", "require"]); + +/** + * Static verification of registered capabilities (manifest mode only). These + * checks mirror what `defineCapability()` and the runtime registry enforce, + * but run without executing application code so `pracht verify` stays fast + * and safe. Spec security rule 1: exposed capabilities without a full + * contract (description, input, output, effect) fail verification. Spec rule + * 3: destructive capabilities may only be exposed over HTTP, and only when + * the prepare/commit confirmation secret (PRACHT_CONFIRMATION_SECRET) is + * configured in the environment `pracht verify` runs in. + */ +export function collectCapabilityChecks(project: ProjectConfig, checks: Check[]): void { + const manifestPath = resolveProjectPath(project.root, project.appFile); + if (!existsSync(manifestPath)) return; + + const manifestSource = readFileSync(manifestPath, "utf-8"); + const entries = extractCapabilityRegistrations(manifestSource).map(({ name, file }) => ({ + name, + path: file, + })); + if (entries.length === 0) return; + const registeredMiddleware = new Set( + extractRegistryEntries(manifestSource, "middleware").map((entry) => entry.name), + ); + + checks.push( + createCheck( + "ok", + `Registered ${entries.length} capabilit${entries.length === 1 ? "y" : "ies"}.`, + ), + ); + + const manifestDir = dirname(manifestPath); + for (const entry of entries) { + // Root-relative refs ("/src/capabilities/x.ts") resolve against the project + // root, matching the runtime registry and the Vite plugin; everything else + // is relative to the manifest. Resolving them all against the manifest + // directory would leave every root-relative capability unverified. + const rootRelative = entry.path.startsWith("/"); + const filePath = rootRelative + ? resolveProjectPath(project.root, entry.path) + : resolve(manifestDir, entry.path); + if (!existsSync(filePath)) { + // The manifest check only reports missing "./"-relative references, so + // root-relative ones have to be reported here or they pass silently. + if (rootRelative) { + checks.push( + createCheck( + "error", + `Capability ${JSON.stringify(entry.name)} references missing file ${JSON.stringify(entry.path)}.`, + ), + ); + } + continue; + } + + collectSingleCapabilityChecks( + entry.name, + entry.path, + readFileSync(filePath, "utf-8"), + registeredMiddleware, + checks, + ); + } +} + +function collectSingleCapabilityChecks( + name: string, + displayPath: string, + source: string, + registeredMiddleware: Set, + checks: Check[], +): void { + const label = `Capability ${JSON.stringify(name)} (${displayPath})`; + const args = extractDefineCapabilityArgs(source); + if (!args) { + checks.push( + createCheck( + "error", + `${label} does not contain a statically analyzable defineCapability({ ... }) call.`, + ), + ); + return; + } + + const properties = scanTopLevelProperties(args); + const title = readStaticString(properties.get("title")); + const description = readStaticString(properties.get("description")); + const effect = readStaticString(properties.get("effect")); + const problems: string[] = []; + + const missing: string[] = []; + if (title.kind === "absent") missing.push("title"); + if (description.kind === "absent") missing.push("description"); + if (!properties.has("input")) missing.push("input schema"); + if (!properties.has("output")) missing.push("output schema"); + if (effect.kind === "absent") missing.push("effect"); + if (missing.length > 0) { + problems.push(`is missing required fields: ${missing.join(", ")}`); + } + + const exposeFlags = readExposeFlags(properties.get("expose")); + const exposed = exposeFlags.hasHttp || exposeFlags.hasMcp || exposeFlags.hasWebmcp; + problems.push(...exposeFlags.problems); + + for (const [field, value] of [ + ["title", title], + ["description", description], + ] as const) { + if (value.kind === "invalid") { + problems.push(`"${field}" must be a non-empty string`); + } else if (value.kind === "unknown") { + checks.push( + createCheck( + "warning", + `${label}: the "${field}" field is not an inline string literal, so it could not be verified statically.`, + ), + ); + } + } + + if (effect.kind === "invalid") { + problems.push('"effect" must be a non-empty string'); + } else if (effect.kind === "unknown") { + if (!exposeFlags.unknown && exposeFlags.hasHttp) { + problems.push( + '"effect" must be an inline "read", "write", or "destructive" string literal for HTTP exposure', + ); + } else { + checks.push( + createCheck( + "warning", + `${label}: the "effect" field is not an inline string literal, so it could not be verified statically.`, + ), + ); + } + } + + const effectValue = effect.kind === "valid" ? effect.value : null; + if (effectValue && !CAPABILITY_EFFECTS.has(effectValue)) { + problems.push('"effect" must be "read", "write", or "destructive"'); + } + + const agentPolicy = readStaticString(properties.get("agentPolicy")); + if (properties.has("agentPolicy")) { + if (agentPolicy.kind === "unknown") { + checks.push( + createCheck( + "warning", + `${label}: the "agentPolicy" field is not an inline string literal, so it could not be verified statically.`, + ), + ); + } else if (agentPolicy.kind !== "valid" || !AGENT_POLICIES.has(agentPolicy.value)) { + problems.push('"agentPolicy" must be "observe" or "require"'); + } + } + + const middleware = readMiddlewareNames(properties.get("middleware")); + if (middleware.kind === "invalid") { + problems.push('"middleware" must be an array of names'); + } else if (middleware.kind === "unknown") { + checks.push( + createCheck( + "warning", + `${label}: the "middleware" field is not an inline array literal, so it could not be verified statically.`, + ), + ); + } else if (middleware.kind === "valid") { + for (const middlewareName of middleware.names) { + if (!registeredMiddleware.has(middlewareName)) { + problems.push(`references unknown middleware ${JSON.stringify(middlewareName)}`); + } + } + } + + if (exposeFlags.unknown) { + checks.push( + createCheck( + "warning", + `${label}: the "expose" field is not an inline object literal, so its exposure contract ` + + "could not be verified statically — including the destructive-exposure and " + + "confirmation-secret checks. Inline the expose object so verification can cover it.", + ), + ); + } + + if (exposed && !exposeFlags.unknown) { + const { hasHttp, hasMcp, hasWebmcp } = exposeFlags; + if (hasWebmcp && !hasHttp) { + problems.push( + "sets expose.webmcp without expose.http — WebMCP tools dispatch through the HTTP projection", + ); + } + + if (hasMcp && effectValue !== "destructive") { + checks.push( + createCheck( + "warning", + `${label} sets expose.mcp, which is recorded in the graph but not served yet — ` + + "the remote MCP projection is capability-graph Stage 2 (see docs/CAPABILITY_GRAPH.md).", + ), + ); + } + + if (effectValue === "destructive") { + if (hasWebmcp || hasMcp) { + problems.push( + "is destructive and exposed to agent projections (webmcp/mcp) — only expose.http " + + "is allowed, gated by the prepare/commit confirmation flow", + ); + } else if (hasHttp && !process.env.PRACHT_CONFIRMATION_SECRET) { + problems.push( + "is destructive and exposed over HTTP without PRACHT_CONFIRMATION_SECRET in the " + + "environment — the prepare/commit confirmation flow needs the secret and the " + + "runtime fails closed without it", + ); + } + } + } + + for (const field of ["input", "output"] as const) { + const schemaText = properties.get(field); + if (!schemaText) continue; + const schema = evaluateLiteral(schemaText); + if (schema === undefined) { + checks.push( + createCheck( + "warning", + `${label}: the "${field}" schema is not an inline object literal, so its JSON Schema subset could not be verified statically.`, + ), + ); + continue; + } + const unsupported = collectUnsupportedSchemaKeywords(schema); + if (unsupported.length > 0) { + problems.push( + `"${field}" schema uses unsupported JSON Schema keywords: ${unsupported.join(", ")}`, + ); + } + const invalid = collectInvalidSchemaKeywordValues(schema); + if (invalid.length > 0) { + problems.push(`"${field}" schema has invalid JSON Schema values: ${invalid.join(", ")}`); + } + } + + if (problems.length > 0) { + for (const problem of problems) { + checks.push(createCheck("error", `${label} ${problem}.`)); + } + return; + } + + if (exposeFlags.unknown) { + // The exposure contract could not be verified; the warning above already + // says so. Don't claim a complete contract. + return; + } + + checks.push( + createCheck( + "ok", + `${label} declares a complete ${exposed ? "exposed" : "private"} contract${effectValue ? ` (effect: ${effectValue})` : ""}.`, + ), + ); +} + +type StaticString = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "unknown" } + | { kind: "valid"; value: string }; + +function readStaticString(text: string | undefined): StaticString { + if (!text) return { kind: "absent" }; + const value = evaluateLiteral(text); + if (value === undefined) return { kind: "unknown" }; + if (typeof value !== "string" || value.trim() === "") return { kind: "invalid" }; + return { kind: "valid", value }; +} + +type MiddlewareNames = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "unknown" } + | { kind: "valid"; names: string[] }; + +function readMiddlewareNames(text: string | undefined): MiddlewareNames { + if (!text) return { kind: "absent" }; + const value = evaluateLiteral(text); + if (value === undefined) return { kind: "unknown" }; + if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) { + return { kind: "invalid" }; + } + return { kind: "valid", names: value }; +} + +function readExposeFlags(text: string | undefined): { + hasHttp: boolean; + hasMcp: boolean; + hasWebmcp: boolean; + /** `expose` is present but not an inline literal, so it can't be verified. */ + unknown: boolean; + problems: string[]; +} { + if (text === undefined) { + return { hasHttp: false, hasMcp: false, hasWebmcp: false, unknown: false, problems: [] }; + } + const value = evaluateLiteral(text); + if (value === undefined) { + return { hasHttp: false, hasMcp: false, hasWebmcp: false, unknown: true, problems: [] }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { + hasHttp: false, + hasMcp: false, + hasWebmcp: false, + unknown: false, + problems: ['"expose" must be an inline object literal'], + }; + } + const expose = value as Record; + const problems: string[] = []; + let hasHttp = false; + if (expose.http === true) { + hasHttp = true; + } else if (expose.http && typeof expose.http === "object" && !Array.isArray(expose.http)) { + hasHttp = true; + const http = expose.http as Record; + if (http.method !== undefined && http.method !== "POST") { + problems.push('HTTP exposure only supports method: "POST"'); + } + if (http.path !== undefined && !isValidCapabilityHttpPath(http.path)) { + problems.push('HTTP exposure "path" must be an exact same-origin pathname starting with "/"'); + } + } else if (expose.http !== undefined && expose.http !== false && expose.http !== null) { + problems.push('"expose.http" must be true or an object'); + } + + return { + hasHttp, + hasMcp: expose.mcp === true, + hasWebmcp: expose.webmcp === true, + unknown: false, + problems, + }; +} diff --git a/packages/cli/src/verification.ts b/packages/cli/src/verification.ts index 72088346..c4458e30 100644 --- a/packages/cli/src/verification.ts +++ b/packages/cli/src/verification.ts @@ -9,6 +9,7 @@ import { collectPackageChecks, collectPagesVerification, } from "./verification-checks.js"; +import { collectCapabilityChecks } from "./verification-capabilities.js"; import { collectEnvLeakVerification } from "./verification-env.js"; import { collectGraphChecks } from "./verification-graph.js"; import { createCheck, type Check } from "./verification-helpers.js"; @@ -87,6 +88,10 @@ export async function runVerification( collectPagesVerification(project, checks, { changedFiles: frameworkFiles, scope }); } else { collectManifestVerification(project, checks, { changedFiles: frameworkFiles, scope }); + // Capability contracts are cheap to verify statically and security + // relevant, so they are always checked in manifest mode (no-op for apps + // without a `capabilities` registry). + collectCapabilityChecks(project, checks); } collectApiVerification(project, checks, { changedFiles: frameworkFiles, scope }); diff --git a/packages/cli/test/dev-banner.test.ts b/packages/cli/test/dev-banner.test.ts index 3db67e12..620ede1b 100644 --- a/packages/cli/test/dev-banner.test.ts +++ b/packages/cli/test/dev-banner.test.ts @@ -116,6 +116,49 @@ describe("formatDevBanner", () => { expect(banner).toContain("API (0)"); expect(banner).toContain("(none)"); }); + + it("lists registered capabilities with effect, exposure, and dispatch path", () => { + const banner = formatDevBanner({ + apiRoutes: [], + capabilities: [ + { + effect: "read", + httpPath: "/api/capabilities/notes/search", + name: "notes.search", + transports: ["http", "webmcp"], + }, + { + effect: "destructive", + httpPath: "/api/capabilities/notes/purge", + name: "notes.purge", + transports: ["http"], + }, + { effect: "read", httpPath: null, name: "notes.internal", transports: [] }, + ], + color: false, + localUrls: ["http://localhost:3000/"], + routes: [], + }); + + expect(banner).toContain("Capabilities (3)"); + expect(banner).toMatch( + /notes\.search\s+read\s+http,webmcp\s+\/api\/capabilities\/notes\/search/, + ); + expect(banner).toMatch(/notes\.purge\s+destructive\s+http\s+\/api\/capabilities\/notes\/purge/); + expect(banner).toMatch(/notes\.internal\s+read\s+private\s+-/); + }); + + it("omits the capabilities section when none are registered", () => { + const banner = formatDevBanner({ + apiRoutes, + capabilities: [], + color: false, + localUrls: ["http://localhost:3000/"], + routes, + }); + + expect(banner).not.toContain("Capabilities"); + }); }); describe("supportsColor", () => { diff --git a/packages/cli/test/eval-runner.test.ts b/packages/cli/test/eval-runner.test.ts new file mode 100644 index 00000000..1b996b7b --- /dev/null +++ b/packages/cli/test/eval-runner.test.ts @@ -0,0 +1,317 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + capabilityHttpPath, + collectExpectationFailures, + findEvalFiles, + matchesSubset, + parseScenario, + resolveStepReferences, + runScenario, + waitForServer, + type EvalScenario, + type EvalStepResult, +} from "../src/eval-runner.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pracht-eval-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function stepResult(overrides: Partial = {}): EvalStepResult { + return { + capability: "notes.search", + status: 200, + ok: true, + latencyMs: 1, + errorCode: null, + failures: [], + resultForReferences: { status: 200, ok: true, data: { notes: [] } }, + ...overrides, + }; +} + +describe("matchesSubset", () => { + it("matches deep subsets of objects", () => { + expect(matchesSubset({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 2 } })).toBe(true); + expect(matchesSubset({ a: 1 }, { a: 2 })).toBe(false); + expect(matchesSubset({ a: 1 }, { missing: 1 })).toBe(false); + }); + + it("compares arrays element-wise with equal length", () => { + expect(matchesSubset([{ id: "a", extra: 1 }], [{ id: "a" }])).toBe(true); + expect(matchesSubset([{ id: "a" }, { id: "b" }], [{ id: "a" }])).toBe(false); + expect(matchesSubset("nope", [{ id: "a" }])).toBe(false); + }); + + it("compares primitives strictly", () => { + expect(matchesSubset(1, 1)).toBe(true); + expect(matchesSubset("1", 1)).toBe(false); + expect(matchesSubset(null, null)).toBe(true); + }); +}); + +describe("collectExpectationFailures", () => { + it("requires an ok envelope when no expectation is declared", () => { + expect(collectExpectationFailures(undefined, 200, { ok: true })).toEqual([]); + expect( + collectExpectationFailures(undefined, 400, { ok: false, error: { code: "invalid_input" } }), + ).toHaveLength(1); + }); + + it("checks ok, status, errorCode, and output subsets", () => { + const envelope = { ok: false, error: { code: "confirmation_required" } }; + expect( + collectExpectationFailures( + { ok: false, status: 409, errorCode: "confirmation_required" }, + 409, + envelope, + ), + ).toEqual([]); + expect(collectExpectationFailures({ status: 200 }, 409, envelope)).toHaveLength(1); + expect(collectExpectationFailures({ errorCode: "forbidden" }, 409, envelope)).toHaveLength(1); + expect( + collectExpectationFailures({ output: { purged: 1 } }, 200, { ok: true, data: { purged: 2 } }), + ).toHaveLength(1); + }); +}); + +describe("resolveStepReferences", () => { + const prior = [ + stepResult({ + resultForReferences: { + status: 409, + ok: false, + error: { code: "confirmation_required", confirmationToken: "v1.abc.def" }, + }, + }), + ]; + + it("substitutes $steps[n]. strings in nested input/headers", () => { + const resolved = resolveStepReferences( + { + headers: { "x-pracht-confirm": "$steps[0].error.confirmationToken" }, + nested: ["$steps[0].status"], + plain: "unchanged", + }, + prior, + ); + expect(resolved).toEqual({ + headers: { "x-pracht-confirm": "v1.abc.def" }, + nested: [409], + plain: "unchanged", + }); + }); + + it("throws on out-of-range steps and unresolvable paths", () => { + expect(() => resolveStepReferences("$steps[3].status", prior)).toThrow(/has not run yet/); + expect(() => resolveStepReferences("$steps[0].error.nope", prior)).toThrow( + /resolved to undefined/, + ); + }); +}); + +describe("scenario discovery and parsing", () => { + it("finds evals/**/*.eval.json when no files are given", () => { + const dir = makeTempDir(); + mkdirSync(join(dir, "evals", "nested"), { recursive: true }); + writeFileSync(join(dir, "evals", "a.eval.json"), "{}"); + writeFileSync(join(dir, "evals", "nested", "b.eval.json"), "{}"); + writeFileSync(join(dir, "evals", "ignored.json"), "{}"); + + const files = findEvalFiles(dir, []); + expect(files).toEqual([ + join(dir, "evals", "a.eval.json"), + join(dir, "evals", "nested", "b.eval.json"), + ]); + expect(findEvalFiles(dir, ["explicit.eval.json"])).toEqual([join(dir, "explicit.eval.json")]); + }); + + it("rejects scenarios without a name or steps", () => { + const dir = makeTempDir(); + const file = join(dir, "bad.eval.json"); + writeFileSync(file, JSON.stringify({ name: "x", steps: [] })); + expect(() => parseScenario(file)).toThrow(/non-empty "steps"/); + writeFileSync(file, JSON.stringify({ steps: [{ capability: "a" }] })); + expect(() => parseScenario(file)).toThrow(/missing a "name"/); + writeFileSync(file, JSON.stringify({ name: "x", steps: [{}] })); + expect(() => parseScenario(file)).toThrow(/missing a "capability"/); + }); +}); + +describe("runScenario", () => { + it("runs steps in order, resolving references and reporting failures", async () => { + const requests: { url: string; headers: Record; body: unknown }[] = []; + const responses = [ + { + status: 409, + body: { ok: false, error: { code: "confirmation_required", confirmationToken: "tok-1" } }, + }, + { status: 200, body: { ok: true, data: { purged: 1 } } }, + ]; + const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(url), + headers: (init?.headers ?? {}) as Record, + body: JSON.parse(String(init?.body)), + }); + const next = responses[requests.length - 1]; + return new Response(JSON.stringify(next.body), { status: next.status }); + }) as typeof fetch; + + const scenario: EvalScenario = { + name: "purge flow", + steps: [ + { + capability: "notes.purge", + input: { titlePrefix: "x" }, + expect: { errorCode: "confirmation_required", status: 409 }, + }, + { + capability: "notes.purge", + input: { titlePrefix: "x" }, + headers: { "x-pracht-confirm": "$steps[0].error.confirmationToken" }, + expect: { ok: true, output: { purged: 1 } }, + }, + ], + }; + + const result = await runScenario(scenario, "purge.eval.json", { + baseUrl: "http://localhost:3103", + fetchImpl, + }); + + expect(result.ok).toBe(true); + expect(result.steps.map((step) => step.failures)).toEqual([[], []]); + expect(requests[0].url).toBe("http://localhost:3103/api/capabilities/notes/purge"); + expect(requests[1].headers["x-pracht-confirm"]).toBe("tok-1"); + }); + + it('sets the confirmation header from the "confirm" step field', async () => { + const requests: { headers: Record }[] = []; + const responses = [ + { + status: 409, + body: { ok: false, error: { code: "confirmation_required", confirmationToken: "tok-9" } }, + }, + { status: 200, body: { ok: true, data: { purged: 2 } } }, + ]; + const fetchImpl = (async (_url: RequestInfo | URL, init?: RequestInit) => { + requests.push({ headers: (init?.headers ?? {}) as Record }); + const next = responses[requests.length - 1]; + return new Response(JSON.stringify(next.body), { status: next.status }); + }) as typeof fetch; + + const result = await runScenario( + { + name: "purge with confirm sugar", + steps: [ + { + capability: "notes.purge", + expect: { errorCode: "confirmation_required" }, + }, + { + capability: "notes.purge", + confirm: "$steps[0].error.confirmationToken", + expect: { ok: true }, + }, + ], + }, + "purge.eval.json", + { baseUrl: "http://localhost:3103", fetchImpl }, + ); + + expect(result.ok).toBe(true); + expect(requests[1].headers["x-pracht-confirm"]).toBe("tok-9"); + }); + + it("fails the scenario when an expectation does not hold", async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 })) as typeof fetch; + const result = await runScenario( + { name: "x", steps: [{ capability: "a.b", expect: { ok: false } }] }, + "x.eval.json", + { baseUrl: "http://localhost", fetchImpl }, + ); + expect(result.ok).toBe(false); + expect(result.steps[0].failures[0]).toContain("expected ok=false"); + }); + + it("surfaces network errors as scenario-level failures", async () => { + const fetchImpl = (async () => { + throw new Error("connection refused"); + }) as typeof fetch; + const result = await runScenario({ name: "x", steps: [{ capability: "a.b" }] }, "x.eval.json", { + baseUrl: "http://localhost:1", + fetchImpl, + }); + expect(result.ok).toBe(false); + expect(result.error).toContain("connection refused"); + }); + + it("maps capability names to default HTTP paths", () => { + expect(capabilityHttpPath("notes.purge")).toBe("/api/capabilities/notes/purge"); + expect(capabilityHttpPath("ping")).toBe("/api/capabilities/ping"); + }); +}); + +describe("waitForServer", () => { + it("resolves ok once the server answers, even with an error status", async () => { + let calls = 0; + const fetchImpl = (async () => { + calls += 1; + if (calls < 3) throw new Error("ECONNREFUSED"); + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await waitForServer("http://localhost:9", { + fetchImpl, + intervalMs: 1, + timeoutMs: 1_000, + }); + expect(result).toEqual({ ok: true }); + expect(calls).toBe(3); + }); + + it("times out with a reason when the server never answers", async () => { + const fetchImpl = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch; + + const result = await waitForServer("http://localhost:9", { + fetchImpl, + intervalMs: 1, + timeoutMs: 20, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected timeout"); + expect(result.reason).toContain("http://localhost:9"); + }); + + it("aborts early when the earlyExit callback reports a reason", async () => { + const fetchImpl = (async () => { + throw new Error("ECONNREFUSED"); + }) as typeof fetch; + + let polls = 0; + const result = await waitForServer("http://localhost:9", { + earlyExit: () => (++polls > 1 ? "start command exited" : null), + fetchImpl, + intervalMs: 1, + timeoutMs: 5_000, + }); + expect(result).toEqual({ ok: false, reason: "start command exited" }); + }); +}); diff --git a/packages/cli/test/mcp-server.test.ts b/packages/cli/test/mcp-server.test.ts index 1433fa39..f8e62d73 100644 --- a/packages/cli/test/mcp-server.test.ts +++ b/packages/cli/test/mcp-server.test.ts @@ -42,6 +42,7 @@ describe("pracht MCP server", () => { "get_docs", "inspect_api", "inspect_build", + "inspect_capabilities", "inspect_routes", "plan", "report", diff --git a/packages/cli/test/pracht-cli.test.js b/packages/cli/test/pracht-cli.test.js index 7d2279f3..0d51e458 100644 --- a/packages/cli/test/pracht-cli.test.js +++ b/packages/cli/test/pracht-cli.test.js @@ -19,6 +19,7 @@ const cliPath = fileURLToPath(new URL("../bin/pracht.js", import.meta.url)); const repoRoot = resolve(dirname(cliPath), "../../.."); const repoTempRoot = resolve(dirname(cliPath), "../test/.tmp"); const coreImportPath = resolve(repoRoot, "packages/framework/src/index.ts"); +const capabilitiesImportPath = resolve(repoRoot, "packages/capabilities/src/index.ts"); const nodeAdapterImportPath = resolve(repoRoot, "packages/adapter-node/src/index.ts"); const vitePluginImportPath = resolve(repoRoot, "packages/vite-plugin/src/index.ts"); const standardSchemaImportPath = resolve( @@ -375,9 +376,15 @@ export const app = defineApp({ mode: "manifest", }); + const capabilities = JSON.parse( + runCli(["inspect", "capabilities", "--json"], { cwd: appDir }).stdout, + ); + expect(capabilities).toEqual({ capabilities: [], mode: "manifest" }); + expect(all).toEqual({ ...routes, ...api, + ...capabilities, ...build, }); }, 30_000); @@ -647,6 +654,43 @@ export function Component() { } }, 30_000); + it("generates capability declarations from capability schemas", () => { + const appDir = createRepoTempDir("pracht-cli-typegen-capabilities-"); + writeTypedManifestApp(appDir, { capabilities: true }); + + const result = JSON.parse(runCli(["typegen", "--json"], { cwd: appDir }).stdout); + const declaration = readFileSync(join(appDir, "src/pracht-capabilities.d.ts"), "utf-8"); + + expect(result).toMatchObject({ + capabilities: 2, + files: ["src/pracht.d.ts", "src/pracht-routes.ts", "src/pracht-capabilities.d.ts"], + ok: true, + }); + expect(declaration).toContain('declare module "@pracht/core"'); + // Input: `limit` declares a default, so callers may omit it. + expect(declaration).toContain('"notes.search": {'); + expect(declaration).toContain('input: { "query": string; "limit"?: number; };'); + // Output: open objects keep an index signature; closed ones do not. + expect(declaration).toContain( + 'output: { "notes": Array>; [key: string]: unknown; };', + ); + expect(declaration).toContain('"notes.set-status": {'); + expect(declaration).toContain('"status": "draft" | "published";'); + expect(declaration).toContain('output: { "updated": true; };'); + + const check = JSON.parse(runCli(["typegen", "--check", "--json"], { cwd: appDir }).stdout); + expect(check).toMatchObject({ capabilities: 2, check: true, ok: true }); + + // Removing every capability rewrites the existing file to the empty + // registration instead of leaving it stale. + writeTypedManifestApp(appDir, { capabilities: false }); + const emptied = JSON.parse(runCli(["typegen", "--json"], { cwd: appDir }).stdout); + expect(emptied).toMatchObject({ capabilities: 0, ok: true }); + expect(readFileSync(join(appDir, "src/pracht-capabilities.d.ts"), "utf-8")).toContain( + "capabilities: Record;", + ); + }, 30_000); + it("generates typed route declarations for pages-router apps", () => { const appDir = createRepoTempDir("pracht-cli-typegen-pages-"); writeInspectablePagesApp(appDir); @@ -1033,7 +1077,7 @@ export const app = defineApp({ ); } -function writeTypedManifestApp(appDir) { +function writeTypedManifestApp(appDir, { capabilities = false } = {}) { const vitePluginImport = pathToFileURL(vitePluginImportPath).href; writeProjectFile( @@ -1060,6 +1104,7 @@ export default defineConfig({ resolve: { alias: { "@pracht/adapter-node": ${JSON.stringify(nodeAdapterImportPath)}, + "@pracht/capabilities": ${JSON.stringify(capabilitiesImportPath)}, "@pracht/core": ${JSON.stringify(coreImportPath)}, }, }, @@ -1072,7 +1117,15 @@ export default defineConfig({ `import { defineApp, route } from "@pracht/core"; export const app = defineApp({ - routes: [ +${ + capabilities + ? ` capabilities: { + "notes.search": () => import("./capabilities/notes-search.ts"), + "notes.set-status": () => import("./capabilities/notes-set-status.ts"), + }, +` + : "" +} routes: [ route("/", "./routes/home.tsx", { id: "home", render: "ssg" }), route("/products/:id", "./routes/product.tsx", { id: "product", render: "ssr" }), route("/dashboard", { @@ -1085,6 +1138,68 @@ export const app = defineApp({ }); `, ); + if (capabilities) { + writeProjectFile( + appDir, + "src/capabilities/notes-search.ts", + `import { defineCapability } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Search notes", + description: "Find notes matching a query.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array", items: { type: "object" } } }, + required: ["notes"], + }, + effect: "read", + expose: { http: true }, + async run() { + return { notes: [] }; + }, +}); +`, + ); + writeProjectFile( + appDir, + "src/capabilities/notes-set-status.ts", + `import { defineCapability } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Set note status", + description: "Move a note between draft and published.", + input: { + type: "object", + properties: { + id: { type: "string" }, + status: { enum: ["draft", "published"] }, + }, + required: ["id", "status"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { updated: { const: true } }, + required: ["updated"], + additionalProperties: false, + }, + effect: "write", + async run() { + return { updated: true }; + }, +}); +`, + ); + } writeProjectFile(appDir, "src/routes/home.tsx", "export function Component() { return null; }\n"); writeProjectFile( appDir, diff --git a/packages/cli/test/verification-capabilities.test.ts b/packages/cli/test/verification-capabilities.test.ts new file mode 100644 index 00000000..927275f3 --- /dev/null +++ b/packages/cli/test/verification-capabilities.test.ts @@ -0,0 +1,648 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { PROJECT_DEFAULTS } from "../src/constants.js"; +import type { ProjectConfig } from "../src/project.js"; +import { collectCapabilityChecks } from "../src/verification-capabilities.js"; +import type { Check } from "../src/verification-helpers.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +function createProject(options: { + capability: string; + manifestPrefix?: string; + middlewareBlock?: string; + registration?: string; +}): ProjectConfig { + const root = mkdtempSync(join(tmpdir(), "pracht-verify-capabilities-")); + tempDirs.push(root); + mkdirSync(join(root, "src/capabilities"), { recursive: true }); + + writeFileSync(join(root, "src/capabilities/notes-search.ts"), options.capability, "utf-8"); + writeFileSync( + join(root, "src/routes.ts"), + [ + 'import { defineApp, route } from "@pracht/core";', + options.manifestPrefix ?? "", + "export const app = defineApp({", + options.middlewareBlock ?? "", + " capabilities: {", + options.registration ?? ' "notes.search": () => import("./capabilities/notes-search.ts"),', + " },", + ' routes: [route("/", () => import("./routes/home.tsx"))],', + "});", + ].join("\n"), + "utf-8", + ); + + return { + ...PROJECT_DEFAULTS, + configFile: join(root, "vite.config.ts"), + hasPrachtPlugin: true, + mode: "manifest", + rawConfig: "", + root, + } as ProjectConfig; +} + +function runChecks(capability: string): Check[] { + const checks: Check[] = []; + collectCapabilityChecks(createProject({ capability }), checks); + return checks; +} + +function capabilitySource(fields: string): string { + return [ + 'import { defineCapability } from "@pracht/capabilities";', + "", + "export default defineCapability({", + fields, + " async run() {", + " return {};", + " },", + "});", + "", + ].join("\n"); +} + +const COMPLETE_FIELDS = ` title: "Search notes", + description: "Find notes.", + input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + output: { type: "object" }, + effect: "read", + expose: { http: true, webmcp: true },`; + +describe("collectCapabilityChecks", () => { + it("passes a complete exposed capability", () => { + const checks = runChecks(capabilitySource(COMPLETE_FIELDS)); + + expect(checks.some((check) => check.status === "error")).toBe(false); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("declares a complete exposed contract (effect: read)"), + ); + }); + + it("warns instead of passing when expose is not an inline literal", () => { + const source = [ + 'import { defineCapability } from "@pracht/capabilities";', + "", + "const EXPOSE = { http: true };", + "export default defineCapability({", + ' title: "Purge notes",', + ' description: "Delete notes.",', + ' input: { type: "object" },', + ' output: { type: "object" },', + ' effect: "destructive",', + " expose: EXPOSE,", + " async run() {", + " return {};", + " },", + "});", + "", + ].join("\n"); + const checks = runChecks(source); + + // The destructive-exposure / confirmation-secret checks can't run, so it + // must warn rather than silently claim a complete contract. + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('the "expose" field is not an inline object literal'), + ); + expect(checks.map((check) => check.message)).not.toContainEqual( + expect.stringContaining("declares a complete"), + ); + }); + + it.each(["true", "null", "[]"])("rejects a non-object inline expose value (%s)", (expose) => { + const checks = runChecks( + capabilitySource(` title: "Search notes", + description: "Find notes.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read", + expose: ${expose},`), + ); + + expect(checks).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('"expose" must be an inline object literal'), + status: "error", + }), + ); + expect(checks.map((check) => check.message)).not.toContainEqual( + expect.stringContaining("declares a complete"), + ); + }); + + it("reports an empty exposure object as a private contract", () => { + const checks = runChecks( + capabilitySource(` title: "Search notes", + description: "Find notes.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read", + expose: {},`), + ); + + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("declares a complete private contract"), + ); + }); + + it("reads the live registry when a block-commented example precedes it", () => { + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: capabilitySource(COMPLETE_FIELDS), + middlewareBlock: [ + " /*", + " capabilities: {", + ' "notes.fake": () => import("./capabilities/notes-fake.ts"),', + " },", + " */", + ].join("\n"), + }), + checks, + ); + + // The commented example must not shadow the live registry: the real + // capability is analyzed (its OK check appears) and the fake one is not. + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("declares a complete exposed contract"), + ); + expect(checks.map((check) => check.message)).not.toContainEqual( + expect.stringContaining("notes.fake"), + ); + }); + + it("does not count a commented-out registration", () => { + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: capabilitySource( + ` title: "Purge", + description: "Delete everything.", + input: { type: "object" }, + output: { type: "object" }, + effect: "destructive", + expose: { http: true },`, + ), + registration: ' // "notes.search": () => import("./capabilities/notes-search.ts"),', + }), + checks, + ); + + // Nothing is registered, so the destructive-without-secret error must not + // fire for the commented-out capability. + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + }); + + it("fails exposed capabilities that are missing contract fields", () => { + const checks = runChecks( + capabilitySource(` title: "Search notes", + input: { type: "object" }, + expose: { http: true },`), + ); + + const errors = checks.filter((check) => check.status === "error"); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain( + "is missing required fields: description, output schema, effect", + ); + }); + + it("fails capabilities that are missing required fields even when private", () => { + const checks = runChecks( + capabilitySource(` description: "Private op.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read",`), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining("is missing required fields: title")); + }); + + it("fails capabilities with invalid effect values", () => { + const checks = runChecks( + capabilitySource(COMPLETE_FIELDS.replace('effect: "read"', 'effect: "publish"')), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining('"effect" must be "read", "write", or "destructive"')); + }); + + it("fails capabilities with invalid agent policy values", () => { + const checks = runChecks( + capabilitySource(`${COMPLETE_FIELDS} + agentPolicy: "signed",`), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining('"agentPolicy" must be "observe" or "require"')); + }); + + it("warns instead of failing when agent policy is not statically analyzable", () => { + const checks = runChecks( + capabilitySource(`${COMPLETE_FIELDS} + agentPolicy: sharedAgentPolicy,`), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('"agentPolicy" field is not an inline string literal'), + ); + }); + + it("fails capabilities that reference unknown middleware", () => { + const checks = runChecks( + capabilitySource(`${COMPLETE_FIELDS} + middleware: ["auth"],`), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining('references unknown middleware "auth"')); + }); + + it("warns instead of failing when middleware is not statically analyzable", () => { + const checks = runChecks( + capabilitySource(`${COMPLETE_FIELDS} + middleware: sharedMiddleware,`), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('"middleware" field is not an inline array literal'), + ); + }); + + it("accepts capabilities that reference registered middleware", () => { + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: capabilitySource(`${COMPLETE_FIELDS} + middleware: ["auth"],`), + middlewareBlock: ' middleware: { auth: () => import("./middleware/auth.ts") },', + }), + checks, + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + }); + + it("fails malformed HTTP exposure config", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + "expose: { http: true, webmcp: true },", + 'expose: { http: { method: "GET", path: "api/custom" } },', + ), + ), + ); + + const errors = checks.filter((check) => check.status === "error").map((check) => check.message); + expect(errors).toContainEqual( + expect.stringContaining('HTTP exposure only supports method: "POST"'), + ); + expect(errors).toContainEqual( + expect.stringContaining( + 'HTTP exposure "path" must be an exact same-origin pathname starting with "/"', + ), + ); + }); + + it("fails destructive capabilities exposed to agent projections", () => { + // COMPLETE_FIELDS exposes http + webmcp — destructive may only use http. + const checks = runChecks( + capabilitySource(COMPLETE_FIELDS.replace('effect: "read"', 'effect: "destructive"')), + ); + + const errors = checks.filter((check) => check.status === "error"); + expect(errors.map((error) => error.message)).toContainEqual( + expect.stringContaining("is destructive and exposed to agent projections"), + ); + }); + + it("fails destructive http exposure without the confirmation secret", () => { + const previous = process.env.PRACHT_CONFIRMATION_SECRET; + delete process.env.PRACHT_CONFIRMATION_SECRET; + try { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace('effect: "read"', 'effect: "destructive"').replace( + "expose: { http: true, webmcp: true },", + "expose: { http: true },", + ), + ), + ); + + const errors = checks.filter((check) => check.status === "error"); + expect(errors.map((error) => error.message)).toContainEqual( + expect.stringContaining("without PRACHT_CONFIRMATION_SECRET"), + ); + } finally { + if (previous !== undefined) process.env.PRACHT_CONFIRMATION_SECRET = previous; + } + }); + + it("fails destructive http exposure when expose keys are quoted", () => { + const previous = process.env.PRACHT_CONFIRMATION_SECRET; + delete process.env.PRACHT_CONFIRMATION_SECRET; + try { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace('effect: "read"', 'effect: "destructive"').replace( + "expose: { http: true, webmcp: true },", + 'expose: { "http": true },', + ), + ), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining("without PRACHT_CONFIRMATION_SECRET")); + } finally { + if (previous !== undefined) process.env.PRACHT_CONFIRMATION_SECRET = previous; + } + }); + + it("accepts destructive http exposure when the confirmation secret is configured", () => { + const previous = process.env.PRACHT_CONFIRMATION_SECRET; + process.env.PRACHT_CONFIRMATION_SECRET = "verify-test-secret"; + try { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace('effect: "read"', 'effect: "destructive"').replace( + "expose: { http: true, webmcp: true },", + "expose: { http: true },", + ), + ), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + } finally { + if (previous === undefined) delete process.env.PRACHT_CONFIRMATION_SECRET; + else process.env.PRACHT_CONFIRMATION_SECRET = previous; + } + }); + + it("fails webmcp exposure without http", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + "expose: { http: true, webmcp: true },", + "expose: { webmcp: true },", + ), + ), + ); + + const errors = checks.filter((check) => check.status === "error"); + expect(errors.map((error) => error.message)).toContainEqual( + expect.stringContaining("sets expose.webmcp without expose.http"), + ); + }); + + it("fails webmcp exposure without http when expose keys are quoted", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + "expose: { http: true, webmcp: true },", + 'expose: { "webmcp": true },', + ), + ), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual(expect.stringContaining("sets expose.webmcp without expose.http")); + }); + + it("fails schemas using unsupported JSON Schema keywords", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + 'input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },', + 'input: { type: "object", properties: { query: { type: "string", pattern: "^a" } } },', + ), + ), + ); + + const errors = checks.filter((check) => check.status === "error"); + expect(errors.map((error) => error.message)).toContainEqual( + expect.stringContaining("unsupported JSON Schema keywords: /properties/query/pattern"), + ); + }); + + it("fails schemas using malformed supported keyword values", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + 'input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },', + 'input: { type: "object", required: "query" },', + ), + ), + ); + + expect( + checks.filter((check) => check.status === "error").map((check) => check.message), + ).toContainEqual( + expect.stringContaining( + '"input" schema has invalid JSON Schema values: /required:', + ), + ); + }); + + it("warns instead of failing when a schema is not statically analyzable", () => { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + 'input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },', + "input: sharedInputSchema,", + ), + ), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("could not be verified statically"), + ); + }); + + it("does not execute expressions while statically verifying fields", () => { + const marker = `__prachtVerifyExecuted_${Date.now()}`; + try { + const checks = runChecks( + capabilitySource( + COMPLETE_FIELDS.replace( + 'input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },', + `input: (() => { globalThis.${marker} = true; return { type: "object" }; })(),`, + ), + ), + ); + + expect((globalThis as Record)[marker]).toBeUndefined(); + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("could not be verified statically"), + ); + } finally { + delete (globalThis as Record)[marker]; + } + }); + + it("warns instead of failing when string metadata is not statically analyzable", () => { + const checks = runChecks( + capabilitySource(` title: sharedTitle, + description: sharedDescription, + input: { type: "object" }, + output: { type: "object" }, + effect: sharedEffect,`), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('"title" field is not an inline string literal'), + ); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('"description" field is not an inline string literal'), + ); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('"effect" field is not an inline string literal'), + ); + }); + + it("fails HTTP exposure when effect is not statically analyzable", () => { + const checks = runChecks( + capabilitySource(` title: "Search", + description: "Find notes.", + input: { type: "object" }, + output: { type: "object" }, + effect: sharedEffect, + expose: { http: true },`), + ); + + expect(checks).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('"effect" must be an inline "read", "write"'), + status: "error", + }), + ); + }); + + it("rejects protocol-relative custom HTTP paths", () => { + const checks = runChecks( + capabilitySource(` title: "Search", + description: "Find notes.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read", + expose: { http: { path: "//evil.test/collect" } },`), + ); + + expect(checks).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining("exact same-origin pathname"), + status: "error", + }), + ); + }); + + it("verifies the capability registry inside the exported app", () => { + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: capabilitySource(`${COMPLETE_FIELDS} + middleware: ["auth"],`), + manifestPrefix: + 'const metadata = { capabilities: { "wrong.tool": () => import("./missing.ts") }, middleware: { wrong: "./wrong.ts" } };', + middlewareBlock: ' middleware: { auth: "./middleware/auth.ts" },', + }), + checks, + ); + + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining('Capability "notes.search"'), + ); + expect(checks.map((check) => check.message)).not.toContainEqual( + expect.stringContaining("wrong.tool"), + ); + expect(checks.map((check) => check.message)).not.toContainEqual( + expect.stringContaining('unknown middleware "auth"'), + ); + }); + + it("does nothing for apps without a capabilities registry", () => { + const checks: Check[] = []; + const project = createProject({ capability: "export default {};", registration: "" }); + collectCapabilityChecks(project, checks); + expect(checks).toEqual([]); + }); + + it("verifies capabilities registered with a root-relative path", () => { + // The runtime registry and the Vite plugin both resolve "/src/..." against + // the project root, so verification has to as well — otherwise the whole + // contract, including the destructive-exposure check, is silently skipped. + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: capabilitySource(` title: "Purge notes", + description: "Delete notes.", + input: { type: "object" }, + output: { type: "object" }, + effect: "destructive", + expose: { http: true, webmcp: true },`), + registration: ' "notes.search": () => import("/src/capabilities/notes-search.ts"),', + }), + checks, + ); + + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("exposed to agent projections (webmcp/mcp)"), + ); + }); + + it("reports a missing root-relative capability file", () => { + // The manifest check only reports missing "./"-relative references. + const checks: Check[] = []; + collectCapabilityChecks( + createProject({ + capability: "export default {};", + registration: ' "notes.search": () => import("/src/capabilities/nope.ts"),', + }), + checks, + ); + + expect(checks).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('references missing file "/src/capabilities/nope.ts"'), + status: "error", + }), + ); + }); + + it("allows private capabilities without exposure metadata", () => { + const checks = runChecks( + capabilitySource(` title: "Private op", + description: "Server-only.", + input: { type: "object" }, + output: { type: "object" }, + effect: "destructive",`), + ); + + expect(checks.filter((check) => check.status === "error")).toHaveLength(0); + expect(checks.map((check) => check.message)).toContainEqual( + expect.stringContaining("declares a complete private contract (effect: destructive)"), + ); + }); +}); diff --git a/packages/framework/package.json b/packages/framework/package.json index 04de147a..dac3169b 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -84,6 +84,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@pracht/capabilities": "workspace:*", "@standard-schema/spec": "^1.0.0", "preact-suspense": "^0.3.0" }, diff --git a/packages/framework/src/app-graph.ts b/packages/framework/src/app-graph.ts index e8f3f390..61263a53 100644 --- a/packages/framework/src/app-graph.ts +++ b/packages/framework/src/app-graph.ts @@ -7,8 +7,10 @@ * module platform-neutral. */ +import { capabilityHttpPath } from "./runtime-capabilities.ts"; import type { HttpMethod, + PrachtCapability, ResolvedApiRoute, ResolvedPrachtApp, ResolvedRoute, @@ -48,8 +50,26 @@ export interface AppGraphApiRoute { path: string; } +export interface AppGraphCapability { + effect: string | null; + /** Reserved for the MCP Apps projection — always false for now. */ + hasUi: false; + httpPath: string | null; + /** Input JSON Schema — feeds `pracht typegen` and agent-facing inspection. */ + input: Record | null; + middleware: string[]; + name: string; + /** Output JSON Schema — feeds `pracht typegen` and agent-facing inspection. */ + output: Record | null; + source: string; + title: string | null; + /** Exposure transports from the capability's `expose` config. */ + transports: string[]; +} + export interface AppGraph { api: AppGraphApiRoute[]; + capabilities: AppGraphCapability[]; routes: AppGraphRoute[]; /** * The app-level not-found page, serialized like a route. `null` when the app @@ -101,6 +121,61 @@ export function serializeApiRoutes( ); } +/** + * Serialize registered capabilities by loading their modules. Modules that + * fail to load (or don't export a capability) still appear in the graph with + * null metadata so inspect/devtools can surface the broken registration. + */ +export function serializeCapabilities( + capabilities: Record | undefined, + access: AppGraphModuleAccess, +): Promise { + return Promise.all( + Object.entries(capabilities ?? {}).map(async ([name, file]) => { + try { + const module = await access.loadModule(file); + const capability = module.default as PrachtCapability | undefined; + if (!capability || capability.kind !== "capability") { + throw new Error("module does not default-export a capability"); + } + + const transports: string[] = []; + if (capability.expose?.http) transports.push("http"); + if (capability.expose?.mcp) transports.push("mcp"); + if (capability.expose?.webmcp) transports.push("webmcp"); + + return { + effect: capability.effect, + hasUi: false as const, + httpPath: capability.expose?.http + ? (capability.expose.http.path ?? capabilityHttpPath(name)) + : null, + input: capability.input ?? null, + middleware: capability.middleware ?? [], + name, + output: capability.output ?? null, + source: file, + title: capability.title, + transports, + }; + } catch { + return { + effect: null, + hasUi: false as const, + httpPath: null, + input: null, + middleware: [], + name, + output: null, + source: file, + title: null, + transports: [], + }; + } + }), + ); +} + export async function buildAppGraph( options: { apiRoutes?: readonly ResolvedApiRoute[]; @@ -110,6 +185,7 @@ export async function buildAppGraph( const notFound = options.app.notFound; return { api: await serializeApiRoutes(options.apiRoutes ?? [], options), + capabilities: await serializeCapabilities(options.app.capabilities, options), notFound: notFound ? serializeAppRoutes([notFound])[0] : null, routes: serializeAppRoutes(options.app.routes), }; diff --git a/packages/framework/src/app.ts b/packages/framework/src/app.ts index ef92ff89..7791da85 100644 --- a/packages/framework/src/app.ts +++ b/packages/framework/src/app.ts @@ -19,6 +19,7 @@ import type { WebhookRevalidatePolicy, PrachtApp, PrachtAppConfig, + PrachtAgentsConfig, } from "./types.ts"; import { formatUnknownNameError } from "./name-suggestions.ts"; import { NOT_FOUND_ROUTE_ID, NOT_FOUND_ROUTE_PATH } from "./runtime-constants.ts"; @@ -123,6 +124,8 @@ export function defineApp(config: PrachtAppConfig): PrachtApp { return { shells: resolveModuleRefRecord(config.shells ?? {}), middleware: resolveModuleRefRecord(config.middleware ?? {}), + capabilities: resolveModuleRefRecord(config.capabilities ?? {}), + agents: config.agents, api: config.api ?? {}, routes: config.routes, notFound: resolveNotFoundDefinition(config.notFound), @@ -180,6 +183,13 @@ export function resolveApp(app: PrachtApp): ResolvedPrachtApp { } } + // Security validation, deliberately OUTSIDE the VALIDATE_MANIFEST guard: + // Vite compiles `import.meta.env.DEV` to `false` in production server/edge + // bundles, which would strip a dev-only check and let a typo'd policy + // (e.g. "requre") silently fail open at dispatch. This runs once per + // manifest resolution, so the cost is negligible. + validateAgentsConfig(app.agents); + for (const node of app.routes) { flattenRouteNode(app, node, inherited, routes); } @@ -187,6 +197,8 @@ export function resolveApp(app: PrachtApp): ResolvedPrachtApp { return { shells: app.shells, middleware: app.middleware, + capabilities: app.capabilities ?? {}, + agents: app.agents, api: app.api, routes, apiRoutes: [], @@ -369,6 +381,45 @@ function hasOwnEntry(record: Record, name: string): boolean { return Object.prototype.hasOwnProperty.call(record, name); } +const AGENT_POLICY_MODES = ["observe", "require"]; + +/** + * Validate `defineApp({ agents })`. The security-relevant setting — the Web + * Bot Auth `policy` — is compared with `=== "require"` at dispatch, so a typo + * (`"requre"`) would silently fail open. Reject unknown policies and + * non-positive numeric trust settings so the manifest fails closed instead. + */ +function validateAgentsConfig(agents: PrachtAgentsConfig | undefined): void { + if (!agents) return; + const { webBotAuth, confirmation } = agents; + if (webBotAuth) { + if (webBotAuth.policy !== undefined && !AGENT_POLICY_MODES.includes(webBotAuth.policy)) { + throw new Error( + `defineApp({ agents.webBotAuth.policy }) must be one of ${AGENT_POLICY_MODES.map((mode) => `"${mode}"`).join(", ")}, got ${JSON.stringify(webBotAuth.policy)}.`, + ); + } + for (const key of [ + "clockSkewSeconds", + "maxLifetimeSeconds", + "directoryCacheTtlSeconds", + ] as const) { + assertPositiveNumber(webBotAuth[key], `agents.webBotAuth.${key}`); + } + } + if (confirmation) { + assertPositiveNumber(confirmation.ttlSeconds, "agents.confirmation.ttlSeconds"); + } +} + +function assertPositiveNumber(value: number | undefined, label: string): void { + if (value === undefined) return; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error( + `defineApp({ ${label} }) must be a positive number, got ${JSON.stringify(value)}.`, + ); + } +} + function isResolvedApp(app: PrachtApp | ResolvedPrachtApp): app is ResolvedPrachtApp { return app.routes.length === 0 || "segments" in app.routes[0]; } diff --git a/packages/framework/src/browser.ts b/packages/framework/src/browser.ts index 3dc9ffb2..ed6ac359 100644 --- a/packages/framework/src/browser.ts +++ b/packages/framework/src/browser.ts @@ -64,6 +64,28 @@ export { useRouteData, } from "./runtime-hooks.ts"; export { prefetch, type PrefetchFn } from "./prefetch-api.ts"; + +/** + * Browser stub for the server-only `invokeCapability()`. Route modules import + * it for their loaders; the client transform strips the loader, but the named + * import can survive when the statement also imports client hooks. This stub + * keeps the capability pipeline out of client bundles and fails loudly if it + * is ever called in the browser. + */ +export async function invokeCapability(): Promise { + throw new Error( + "invokeCapability() is server-only. In the browser, call the HTTP projection " + + 'via callCapability from "virtual:pracht/capabilities" instead.', + ); +} + +/** Browser stub for the server-only `createCapabilityTestHost()` — see above. */ +export function createCapabilityTestHost(): never { + throw new Error( + "createCapabilityTestHost() is server-only. Import it in Node-based tests, " + + "not in browser code.", + ); +} export { fetchPrachtRouteState, parseSafeNavigationUrl } from "./runtime-client-fetch.ts"; export { initClientRouter, useNavigate } from "./router.ts"; export { redirect, type RedirectOptions } from "./runtime-middleware.ts"; diff --git a/packages/framework/src/devtools.ts b/packages/framework/src/devtools.ts index 8d4af1f6..ca543ded 100644 --- a/packages/framework/src/devtools.ts +++ b/packages/framework/src/devtools.ts @@ -14,11 +14,13 @@ export { detectApiMethods, serializeApiRoutes, serializeAppRoutes, + serializeCapabilities, } from "./app-graph.ts"; export type { ApiRouteExports, AppGraph, AppGraphApiRoute, + AppGraphCapability, AppGraphModuleAccess, AppGraphRoute, } from "./app-graph.ts"; @@ -59,6 +61,32 @@ export function buildDevtoolsHtml(graph: AppGraph): string { ) .join("\n"); + const capabilityRows = (graph.capabilities ?? []) + .map( + (capability) => ` + ${escapeHtml(capability.name)} + ${escapeHtml(capability.effect ?? "—")} + ${escapeHtml(capability.transports.length > 0 ? capability.transports.join(", ") : "private")} + ${escapeHtml(capability.httpPath ?? "—")} + ${escapeHtml(capability.middleware.length > 0 ? capability.middleware.join(" → ") : "—")} + ${escapeHtml(capability.source)} + `, + ) + .join("\n"); + + // Only rendered when the app registers capabilities — the devtools page is + // byte-for-byte unchanged for apps that don't use them. + const capabilitiesSection = + (graph.capabilities ?? []).length > 0 + ? `

Capabilities

+ + + +${capabilityRows} + +
NameEffectTransportsHTTP pathMiddlewareSource
` + : ""; + const apiSection = graph.api.length > 0 ? `

API routes

@@ -175,6 +203,7 @@ ${notFoundRow} ${apiSection} + ${capabilitiesSection}
Raw JSON at ${DEVTOOLS_JSON_PATH} · same data as pracht inspect --json · diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 8e8b89dc..f160b595 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -75,16 +75,41 @@ export { detectApiMethods, serializeApiRoutes, serializeAppRoutes, + serializeCapabilities, } from "./app-graph.ts"; export type { ApiRouteExports, AppGraph, AppGraphApiRoute, + AppGraphCapability, AppGraphModuleAccess, AppGraphRoute, } from "./app-graph.ts"; export { filterPublicEnv, PRACHT_PUBLIC_ENV_PREFIX, publicEnv } from "./env.ts"; export type { PrachtPublicEnv, PrachtServerEnv, PublicEnvOf } from "./env.ts"; +export { + capabilityHttpPath, + invokeCapability, + matchCapabilityRoute, + resolveAppCapabilities, + setCapabilityAuditHook, +} from "./runtime-capabilities.ts"; +export type { InvokeCapabilityContext, ResolvedCapability } from "./runtime-capabilities.ts"; +export { resolveRegistryModule } from "./runtime-manifest.ts"; +export { createCapabilityTestHost } from "./testing-capabilities.ts"; +export type { + CapabilityTestHost, + CapabilityTestHostOptions, + CapabilityTestInvokeOptions, + CapabilityTestRequestOptions, +} from "./testing-capabilities.ts"; +export { verifyAgentSignature } from "./runtime-agent-auth.ts"; +export type { VerifyAgentSignatureOptions } from "./runtime-agent-auth.ts"; +export { + CONFIRMATION_HEADER, + CONFIRMATION_SECRET_ENV, + setCapabilityConfirmationSecret, +} from "./runtime-confirmation.ts"; export { forwardRef } from "./forwardRef.ts"; export { useIsHydrated } from "./hydration.ts"; export { Suspense, lazy } from "preact-suspense"; @@ -140,7 +165,32 @@ export type { BuildHrefOptions, ApiRouteMatch, ApiRouteModule, + AgentPolicyMode, BaseRouteArgs, + CapabilityAuditEvent, + CapabilityAuditHook, + CapabilityConfirmationConfig, + CapabilityEffect, + PrachtAgentIdentity, + PrachtAgentsConfig, + WebBotAuthConfig, + WebBotAuthStaticKey, + CapabilityContext, + CapabilityEnvelope, + CapabilityErrorCode, + CapabilityErrorPayload, + CapabilityExposure, + CapabilityHttpExposure, + CapabilityInputFor, + CapabilityIssue, + CapabilityModule, + CapabilityOutputFor, + CapabilityRunArgs, + CapabilityValidationResult, + PrachtContextExtensions, + PrachtRequestContext, + RegisteredCapabilityName, + PrachtCapability, DataModule, ErrorBoundaryProps, GroupDefinition, diff --git a/packages/framework/src/islands-client.ts b/packages/framework/src/islands-client.ts index f97d5130..ba0682d3 100644 --- a/packages/framework/src/islands-client.ts +++ b/packages/framework/src/islands-client.ts @@ -1,6 +1,8 @@ import { h, hydrate } from "preact"; import type { ComponentType } from "preact"; +import { CAPABILITY_SETTLED_EVENT } from "@pracht/capabilities"; + import { ISLAND_ELEMENT, ISLAND_EXPORT_ATTRIBUTE, @@ -28,7 +30,29 @@ export interface HydrateIslandsOptions { modules: Record Promise>; } +let capabilityRevalidationBound = false; + +/** + * Islands routes render server-side and mount no client router, so there is no + * route-data store to soft-refresh after a mutation the way full-hydration + * routes do. Reload the document instead when a non-`read` capability settles + * successfully, so loader-rendered content stays consistent with the mutation. + */ +function bindCapabilityRevalidation(): void { + if (capabilityRevalidationBound || typeof window === "undefined") return; + capabilityRevalidationBound = true; + window.addEventListener(CAPABILITY_SETTLED_EVENT, (event) => { + const detail = (event as CustomEvent).detail as + | { ok?: boolean; effect?: string; revalidate?: boolean } + | undefined; + if (detail?.ok === true && detail.effect !== "read" && detail.revalidate !== false) { + window.location.reload(); + } + }); +} + export async function hydrateIslands(options: HydrateIslandsOptions): Promise { + bindCapabilityRevalidation(); const elements = document.querySelectorAll(ISLAND_ELEMENT); const immediate: Promise[] = []; diff --git a/packages/framework/src/llms-txt.ts b/packages/framework/src/llms-txt.ts new file mode 100644 index 00000000..713fd173 --- /dev/null +++ b/packages/framework/src/llms-txt.ts @@ -0,0 +1,223 @@ +/** + * llms.txt generation (https://llmstxt.org) from the resolved app graph. + * + * `pracht build` writes the result to `dist/client/llms.txt` and the dev SSR + * middleware serves it live at `/llms.txt` when the vite plugin's `llmsTxt` + * option is enabled. Output is deterministic: entries are sorted by path and + * dynamic SSG/ISG routes are expanded through their `getStaticPaths()` + * export. Dynamic routes without enumerable instances (e.g. SSR routes with + * params) are skipped — they have no concrete URL an agent could fetch. + * HTTP-exposed capabilities are listed with their dispatch endpoint, effect + * class, and description so agents can discover callable operations, not + * just readable pages. + */ + +import { buildPathFromSegments } from "./app.ts"; +import { API_METHOD_ORDER } from "./app-graph.ts"; +import { resolveAppCapabilities } from "./runtime-capabilities.ts"; +import { resolveRegistryModule } from "./runtime-manifest.ts"; +import type { + ApiRouteModule, + ModuleRegistry, + ResolvedApiRoute, + ResolvedPrachtApp, + ResolvedRoute, + RouteModule, + RouteParams, +} from "./types.ts"; + +export type LlmsTxtSection = "pages" | "api" | "capabilities"; + +export interface BuildLlmsTxtOptions { + app: ResolvedPrachtApp; + apiRoutes?: readonly ResolvedApiRoute[]; + registry?: ModuleRegistry; + /** H1 project title — the only required llms.txt element. */ + title: string; + /** Blockquote summary rendered under the title. Omitted when empty. */ + description?: string; + /** + * Origin (e.g. "https://example.com") prepended to every link so the file + * contains absolute URLs. Links stay root-relative when omitted. + */ + origin?: string; + /** Sections to emit. Defaults to "pages", "api", and "capabilities". */ + include?: readonly LlmsTxtSection[]; +} + +interface LlmsTxtPageEntry { + path: string; + /** True when the route module exports a server-only `markdown` string. */ + markdown: boolean; +} + +interface LlmsTxtApiEntry { + path: string; + methods: string[]; +} + +interface LlmsTxtCapabilityEntry { + name: string; + path: string; + description: string; + effect: string; +} + +export async function buildLlmsTxt(options: BuildLlmsTxtOptions): Promise { + const include = options.include ?? ["pages", "api", "capabilities"]; + const origin = options.origin?.replace(/\/$/, "") ?? ""; + + const lines: string[] = [`# ${options.title}`]; + if (options.description) { + lines.push("", `> ${options.description}`); + } + + if (include.includes("pages")) { + const pages = await collectPageEntries(options.app.routes, options.registry); + if (pages.length > 0) { + lines.push("", "## Pages", ""); + for (const page of pages) { + const note = page.markdown ? ": supports `Accept: text/markdown`" : ""; + lines.push(`- [${page.path}](${origin}${page.path})${note}`); + } + } + } + + if (include.includes("api")) { + const apiEntries = await collectApiEntries(options.apiRoutes ?? [], options.registry); + if (apiEntries.length > 0) { + lines.push("", "## API", ""); + for (const entry of apiEntries) { + const note = entry.methods.length > 0 ? `: ${entry.methods.join(", ")}` : ""; + lines.push(`- [${entry.path}](${origin}${entry.path})${note}`); + } + } + } + + if (include.includes("capabilities")) { + const capabilityEntries = await collectCapabilityEntries(options.app, options.registry); + if (capabilityEntries.length > 0) { + lines.push("", "## Capabilities", ""); + for (const entry of capabilityEntries) { + const confirmation = entry.effect === "destructive" ? ", requires confirmation" : ""; + const description = entry.description ? ` — ${entry.description}` : ""; + lines.push( + `- [${entry.name}](${origin}${entry.path}): POST (${entry.effect}${confirmation})${description}`, + ); + } + } + } + + return `${lines.join("\n")}\n`; +} + +function isDynamicRoute(route: ResolvedRoute): boolean { + return route.segments.some((segment) => segment.type === "param" || segment.type === "catchall"); +} + +/** Locale-independent path ordering so output is byte-stable across machines. */ +function comparePaths(left: string, right: string): number { + if (left === right) return 0; + return left < right ? -1 : 1; +} + +async function loadRouteModule( + registry: ModuleRegistry | undefined, + file: string, +): Promise { + try { + return await resolveRegistryModule(registry?.routeModules, file); + } catch { + return undefined; + } +} + +async function collectPageEntries( + routes: readonly ResolvedRoute[], + registry: ModuleRegistry | undefined, +): Promise { + const entries = new Map(); + + for (const route of routes) { + const routeModule = await loadRouteModule(registry, route.file); + const markdown = typeof routeModule?.markdown === "string"; + + if (!isDynamicRoute(route)) { + if (!entries.has(route.path)) { + entries.set(route.path, { markdown, path: route.path }); + } + continue; + } + + // Dynamic routes only have concrete URLs when they are SSG/ISG with a + // getStaticPaths() export — list each prerendered instance. Other dynamic + // routes (SSR/SPA params) have no enumerable URLs and are skipped. + if (route.render !== "ssg" && route.render !== "isg") continue; + if (typeof routeModule?.getStaticPaths !== "function") continue; + + let paramSets: RouteParams[]; + try { + paramSets = await routeModule.getStaticPaths(); + } catch { + continue; + } + + for (const params of paramSets) { + const path = buildPathFromSegments(route.segments, params); + if (!entries.has(path)) { + entries.set(path, { markdown, path }); + } + } + } + + return [...entries.values()].sort((left, right) => comparePaths(left.path, right.path)); +} + +async function collectApiEntries( + apiRoutes: readonly ResolvedApiRoute[], + registry: ModuleRegistry | undefined, +): Promise { + const entries: LlmsTxtApiEntry[] = []; + + for (const route of apiRoutes) { + let apiModule: ApiRouteModule | undefined; + try { + apiModule = await resolveRegistryModule(registry?.apiModules, route.file); + } catch { + apiModule = undefined; + } + + const methods = apiModule + ? API_METHOD_ORDER.filter((method) => typeof apiModule[method] === "function") + : []; + entries.push({ methods, path: route.path }); + } + + return entries.sort((left, right) => comparePaths(left.path, right.path)); +} + +// Only HTTP-exposed capabilities are listed — private ones have no URL an +// agent could call, and webmcp exposure requires expose.http anyway. Invalid +// capability registrations propagate as errors, matching HTTP dispatch and +// `pracht inspect` rather than silently emitting an incomplete file. +async function collectCapabilityEntries( + app: ResolvedPrachtApp, + registry: ModuleRegistry | undefined, +): Promise { + if (!registry?.capabilityModules) return []; + if (Object.keys(app.capabilities ?? {}).length === 0) return []; + + const resolved = await resolveAppCapabilities(app, registry); + const entries: LlmsTxtCapabilityEntry[] = []; + for (const { name, capability, httpPath } of resolved) { + if (!httpPath) continue; + entries.push({ + description: capability.description ?? "", + effect: capability.effect, + name, + path: httpPath, + }); + } + + return entries.sort((left, right) => comparePaths(left.name, right.name)); +} diff --git a/packages/framework/src/runtime-agent-auth.ts b/packages/framework/src/runtime-agent-auth.ts new file mode 100644 index 00000000..8cbd7d9b --- /dev/null +++ b/packages/framework/src/runtime-agent-auth.ts @@ -0,0 +1,523 @@ +/** + * Web Bot Auth: verified agent identity over RFC 9421 HTTP Message Signatures. + * + * Implements the verifier side of + * draft-meunier-web-bot-auth-architecture-02 (protocol) and + * draft-meunier-http-message-signatures-directory-03 (key discovery): + * + * Signature-Agent: "https://signature-agent.example" + * Signature-Input: sig=("@authority" "signature-agent");created=...; + * expires=...;keyid="";alg="ed25519"; + * nonce="...";tag="web-bot-auth" + * Signature: sig=:: + * + * Everything is Web-platform only (Headers, fetch, crypto.subtle) so Node, + * Cloudflare, and Vercel adapters share one implementation. The structured + * field parsing below is a deliberate hand-rolled subset of RFC 8941 — just + * dictionaries of inner-lists/byte-sequences with parameters, which is all + * these three headers use — to avoid a runtime dependency. + * + * The verifier fails closed: any parse error, missing component, expired or + * not-yet-valid window, unknown key, or bad signature yields `null`, never a + * partially trusted identity. + */ + +import type { PrachtAgentIdentity, WebBotAuthConfig, WebBotAuthStaticKey } from "./types.ts"; + +export const SIGNATURE_AGENT_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"; + +/** The draft requires this tag; signatures with other tags are ignored. */ +const WEB_BOT_AUTH_TAG = "web-bot-auth"; + +const DEFAULT_CLOCK_SKEW_SECONDS = 60; +/** Draft recommends signature expiry "no more than 24 hours" after creation. */ +const DEFAULT_MAX_LIFETIME_SECONDS = 86_400; +const DEFAULT_DIRECTORY_CACHE_TTL_SECONDS = 300; +/** Cap on directory response bodies — a JWKS is tiny; anything bigger is hostile. */ +const DIRECTORY_MAX_BYTES = 65_536; +const DIRECTORY_FETCH_TIMEOUT_MS = 5_000; + +// --------------------------------------------------------------------------- +// Minimal RFC 8941 structured-field parsing (dictionaries only) +// --------------------------------------------------------------------------- + +interface SignatureInputMember { + label: string; + /** Covered component identifiers, e.g. `@authority`, `signature-agent`. */ + components: string[]; + /** Signature parameters: created/expires are numbers, the rest strings. */ + params: Record; + /** + * The member's raw serialization (`("@authority" ...);created=...`) — + * RFC 9421 requires the `@signature-params` base line to reproduce it + * byte-for-byte. + */ + raw: string; +} + +/** Split a dictionary header on top-level commas (quotes and inner lists respected). */ +function splitDictionaryMembers(value: string): string[] { + const members: string[] = []; + let depth = 0; + let inString = false; + let start = 0; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (inString) { + if (char === "\\") index += 1; + else if (char === '"') inString = false; + continue; + } + if (char === '"') inString = true; + else if (char === "(") depth += 1; + else if (char === ")") depth -= 1; + else if (char === "," && depth === 0) { + members.push(value.slice(start, index)); + start = index + 1; + } + } + members.push(value.slice(start)); + return members.map((member) => member.trim()).filter((member) => member !== ""); +} + +/** Parse `;key=value;...` parameters. Returns null on malformed input. */ +function parseParameters(raw: string): Record | null { + const params: Record = {}; + let rest = raw; + while (rest !== "") { + if (!rest.startsWith(";")) return null; + rest = rest.slice(1).trimStart(); + const match = /^([a-z*][a-z0-9_.*-]*)=/.exec(rest); + if (!match) return null; + const key = match[1]; + rest = rest.slice(match[0].length); + if (rest.startsWith('"')) { + const end = findStringEnd(rest); + if (end === -1) return null; + params[key] = unescapeSfString(rest.slice(1, end)); + rest = rest.slice(end + 1); + } else { + const valueMatch = /^-?\d+/.exec(rest); + if (!valueMatch) return null; + params[key] = Number(valueMatch[0]); + rest = rest.slice(valueMatch[0].length); + } + rest = rest.trimStart(); + } + return params; +} + +function findStringEnd(value: string): number { + for (let index = 1; index < value.length; index += 1) { + if (value[index] === "\\") { + index += 1; + continue; + } + if (value[index] === '"') return index; + } + return -1; +} + +function unescapeSfString(value: string): string { + return value.replace(/\\(.)/g, "$1"); +} + +/** + * Parse a `Signature-Input` dictionary: `label=("comp" ...);param=...`. + * Returns null when any member is malformed (fail closed — a partially + * parsed header must not be verified against). + */ +export function parseSignatureInput(header: string): SignatureInputMember[] | null { + const members: SignatureInputMember[] = []; + for (const memberText of splitDictionaryMembers(header)) { + const eq = memberText.indexOf("="); + if (eq === -1) return null; + const label = memberText.slice(0, eq).trim(); + const raw = memberText.slice(eq + 1).trim(); + if (!raw.startsWith("(")) return null; + const close = raw.indexOf(")"); + if (close === -1) return null; + + const componentsText = raw.slice(1, close).trim(); + const components: string[] = []; + if (componentsText !== "") { + for (const item of componentsText.split(/\s+/)) { + if (!item.startsWith('"') || !item.endsWith('"') || item.length < 2) return null; + components.push(unescapeSfString(item.slice(1, -1))); + } + } + + const params = parseParameters(raw.slice(close + 1).trim()); + if (!params) return null; + members.push({ label, components, params, raw }); + } + return members; +} + +/** Parse a `Signature` dictionary of byte sequences: `label=:base64:`. */ +export function parseSignatureHeader(header: string): Record | null { + const signatures: Record = {}; + for (const memberText of splitDictionaryMembers(header)) { + const match = /^([^=]+)=:([A-Za-z0-9+/]*={0,2}):$/.exec(memberText.trim()); + if (!match) return null; + let bytes: Uint8Array; + try { + bytes = base64Decode(match[2]); + } catch { + return null; + } + signatures[match[1].trim()] = bytes; + } + return signatures; +} + +// --------------------------------------------------------------------------- +// RFC 9421 signature base +// --------------------------------------------------------------------------- + +/** + * Build the signature base for the covered components. Only the derived + * components an HTTP verifier can compute from a Web `Request` are supported; + * an unrecognized component fails the whole verification. + */ +export function buildSignatureBase(request: Request, member: SignatureInputMember): string | null { + const url = new URL(request.url); + const lines: string[] = []; + + for (const component of member.components) { + let value: string | null = null; + if (component.startsWith("@")) { + switch (component) { + case "@authority": + value = url.host.toLowerCase(); + break; + case "@method": + value = request.method.toUpperCase(); + break; + case "@scheme": + value = url.protocol.replace(/:$/, ""); + break; + case "@target-uri": + value = request.url; + break; + case "@path": + value = url.pathname; + break; + case "@query": + value = url.search === "" ? "?" : url.search; + break; + default: + return null; + } + } else { + // Header component: RFC 9421 uses the comma-joined, trimmed field value. + const headerValue = request.headers.get(component); + if (headerValue === null) return null; + value = headerValue.trim().replace(/[\r\n]+\s*/g, " "); + } + lines.push(`"${component}": ${value}`); + } + + // The final line reproduces the received serialization (minus the label) + // byte-for-byte, per RFC 9421 §2.3. + lines.push(`"@signature-params": ${member.raw}`); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +const encoder = new TextEncoder(); + +function base64UrlDecode(value: string): Uint8Array { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + return base64Decode(normalized); +} + +function base64Decode(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** + * RFC 8037 Appendix A.3 JWK thumbprint for an Ed25519 public key: SHA-256 + * over the canonical `{"crv","kty","x"}` JSON, base64url encoded. This is + * the `keyid` Web Bot Auth agents send. + */ +export async function ed25519JwkThumbprint(x: string): Promise { + const canonical = JSON.stringify({ crv: "Ed25519", kty: "OKP", x }); + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonical)); + return base64UrlEncode(new Uint8Array(digest)); +} + +interface ResolvedAgentKey { + keyId: string; + /** Base64url raw public key (JWK `x`). */ + x: string; + /** Agent label for identities resolved from static keys. */ + agent: string | null; +} + +/** Directory cache: origin → { keys, expiresAt (ms) }. Per-instance, best effort. */ +const directoryCache = new Map(); + +/** Test hook — clears the module-level directory cache. */ +export function clearAgentDirectoryCache(): void { + directoryCache.clear(); +} + +async function resolveStaticKey( + keys: WebBotAuthStaticKey[] | undefined, + keyId: string, +): Promise { + for (const key of keys ?? []) { + if (typeof key.x !== "string" || key.x === "") continue; + const kid = key.kid ?? (await ed25519JwkThumbprint(key.x)); + if (kid === keyId) { + return { keyId, x: key.x, agent: key.agent ?? null }; + } + } + return null; +} + +/** + * Fetch and parse an agent's key directory (JWKS) with strict validation: + * https only, allowlisted origin, no redirects, response size cap, Ed25519 + * OKP keys only, and each key's thumbprint must match its advertised `kid` + * (when present). Failures return an empty key set — fail closed. + */ +async function fetchAgentDirectory( + origin: string, + cacheTtlSeconds: number, + fetchImpl: typeof fetch, +): Promise { + const cached = directoryCache.get(origin); + if (cached && cached.expiresAt > Date.now()) { + return cached.keys; + } + + let keys: ResolvedAgentKey[] = []; + try { + const response = await fetchImpl(`${origin}${SIGNATURE_AGENT_DIRECTORY_PATH}`, { + redirect: "error", + signal: AbortSignal.timeout(DIRECTORY_FETCH_TIMEOUT_MS), + headers: { accept: "application/http-message-signatures-directory+json" }, + }); + if (response.ok) { + const body = await readBodyWithCap(response, DIRECTORY_MAX_BYTES); + const parsed: unknown = body === null ? null : JSON.parse(body); + keys = await parseDirectoryJwks(parsed); + } + } catch { + keys = []; + } + + directoryCache.set(origin, { keys, expiresAt: Date.now() + cacheTtlSeconds * 1000 }); + return keys; +} + +async function readBodyWithCap(response: Response, maxBytes: number): Promise { + const declaredLength = Number(response.headers.get("content-length") ?? "0"); + if (declaredLength > maxBytes) return null; + if (!response.body) return ""; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + + const buffer = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(buffer); +} + +/** Parse a JWKS payload into Ed25519 keys keyed by thumbprint. Invalid entries are dropped. */ +export async function parseDirectoryJwks(parsed: unknown): Promise { + if (!parsed || typeof parsed !== "object") return []; + const rawKeys = (parsed as { keys?: unknown }).keys; + if (!Array.isArray(rawKeys)) return []; + + const keys: ResolvedAgentKey[] = []; + for (const entry of rawKeys) { + if (!entry || typeof entry !== "object") continue; + const jwk = entry as { kty?: unknown; crv?: unknown; x?: unknown; kid?: unknown }; + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string") continue; + const thumbprint = await ed25519JwkThumbprint(jwk.x); + // A directory advertising a kid that is not the key's thumbprint is + // malformed per the directory draft — drop the entry. + if (typeof jwk.kid === "string" && jwk.kid !== thumbprint) continue; + keys.push({ keyId: thumbprint, x: jwk.x, agent: null }); + } + return keys; +} + +// --------------------------------------------------------------------------- +// Verification +// --------------------------------------------------------------------------- + +export interface VerifyAgentSignatureOptions extends WebBotAuthConfig { + /** Injectable clock (unix seconds) and fetch for tests. */ + now?: () => number; + fetchImpl?: typeof fetch; +} + +/** + * Verify a Web Bot Auth signature on the request. Resolves to the verified + * agent identity, or `null` when the request is unsigned or verification + * fails for any reason (fail closed — this function never throws). + */ +export async function verifyAgentSignature( + request: Request, + options: VerifyAgentSignatureOptions, +): Promise { + try { + return await verifyAgentSignatureUnsafe(request, options); + } catch { + return null; + } +} + +async function verifyAgentSignatureUnsafe( + request: Request, + options: VerifyAgentSignatureOptions, +): Promise { + const signatureInputHeader = request.headers.get("signature-input"); + const signatureHeader = request.headers.get("signature"); + if (!signatureInputHeader || !signatureHeader) return null; + + const members = parseSignatureInput(signatureInputHeader); + const signatures = parseSignatureHeader(signatureHeader); + if (!members || !signatures) return null; + + const now = options.now?.() ?? Math.floor(Date.now() / 1000); + const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS; + const maxLifetime = options.maxLifetimeSeconds ?? DEFAULT_MAX_LIFETIME_SECONDS; + + // The Signature-Agent value is an sf-string containing the directory URL. + const signatureAgentHeader = request.headers.get("signature-agent"); + const agentUrl = signatureAgentHeader ? parseSignatureAgent(signatureAgentHeader) : null; + if (signatureAgentHeader && !agentUrl) return null; + + for (const member of members) { + // Only web-bot-auth signatures concern us; other tags are ignored. + if (member.params.tag !== WEB_BOT_AUTH_TAG) continue; + + const signature = signatures[member.label]; + if (!signature) continue; + + // Required covered components: @authority always; signature-agent + // whenever the header is present (draft §4.2.1). + if (!member.components.includes("@authority")) continue; + if (signatureAgentHeader && !member.components.includes("signature-agent")) continue; + + // Required parameters and freshness window (with clock-skew allowance). + const { created, expires, keyid, alg } = member.params; + if (typeof created !== "number" || typeof expires !== "number") continue; + if (typeof keyid !== "string" || keyid === "") continue; + if (alg !== undefined && alg !== "ed25519") continue; + if (expires <= created || expires - created > maxLifetime) continue; + if (created > now + skew) continue; + if (expires < now - skew) continue; + + // Key resolution: static keys first, then the allowlisted directory. + let key = await resolveStaticKey(options.keys, keyid); + let resolvedFromDirectory = false; + let agentDomain = key?.agent ?? null; + if (!key && agentUrl) { + const allowed = (options.directories ?? []).some( + (directory) => normalizeOrigin(directory) === agentUrl.origin, + ); + if (allowed) { + const directoryKeys = await fetchAgentDirectory( + agentUrl.origin, + options.directoryCacheTtlSeconds ?? DEFAULT_DIRECTORY_CACHE_TTL_SECONDS, + options.fetchImpl ?? fetch, + ); + key = directoryKeys.find((candidate) => candidate.keyId === keyid) ?? null; + resolvedFromDirectory = key !== null; + } + } + if (!key) continue; + if (resolvedFromDirectory && agentUrl) agentDomain = agentUrl.host; + + const base = buildSignatureBase(request, member); + if (base === null) continue; + + const cryptoKey = await crypto.subtle.importKey( + "raw", + toArrayBuffer(base64UrlDecode(key.x)), + { name: "Ed25519" }, + false, + ["verify"], + ); + const valid = await crypto.subtle.verify( + { name: "Ed25519" }, + cryptoKey, + toArrayBuffer(signature), + encoder.encode(base), + ); + if (valid) { + return { verified: true, agentDomain, keyId: keyid }; + } + } + + return null; +} + +/** Copy into a fresh ArrayBuffer — some WebCrypto impls reject SharedArrayBuffer views. */ +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +/** Parse the Signature-Agent sf-string into a validated https URL. */ +export function parseSignatureAgent(header: string): URL | null { + const trimmed = header.trim(); + let value = trimmed; + if (trimmed.startsWith('"')) { + if (!trimmed.endsWith('"') || trimmed.length < 2) return null; + value = unescapeSfString(trimmed.slice(1, -1)); + } + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.protocol !== "https:") return null; + if (url.username !== "" || url.password !== "") return null; + return url; +} + +function normalizeOrigin(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} diff --git a/packages/framework/src/runtime-capabilities.ts b/packages/framework/src/runtime-capabilities.ts new file mode 100644 index 00000000..218196c5 --- /dev/null +++ b/packages/framework/src/runtime-capabilities.ts @@ -0,0 +1,903 @@ +/** + * Capability registry and execution pipeline. + * + * Capabilities are registered in the app manifest (like shells/middleware) + * and executed through one pipeline regardless of how they are invoked: + * + * input validation → named middleware chain → run() → output validation + * + * Both the generated HTTP projection (`handlePrachtRequest`) and direct + * server-side use (`invokeCapability`) call the same pipeline, so business + * rules can never diverge between transports. Capabilities are private by + * default — only `expose.http` makes one reachable over the network. + */ + +import { + CAPABILITY_EFFECT_HEADER, + CAPABILITY_HTTP_PREFIX, + CAPABILITY_TRANSPORT_HEADER, + capabilityHttpPath, + coerceFormInput, + isValidCapabilityHttpPath, + normalizeCapabilityHttpPath, +} from "@pracht/capabilities"; +import { formatUnknownNameError } from "./name-suggestions.ts"; +import { + canonicalJson, + CONFIRMATION_HEADER, + CONFIRMATION_SECRET_ENV, + consumeConfirmationToken, + createConfirmationToken, + DEFAULT_CONFIRMATION_TTL_SECONDS, + resolveConfirmationSecret, + verifyConfirmationToken, +} from "./runtime-confirmation.ts"; +import { resolveRegistryModule } from "./runtime-manifest.ts"; +import { runMiddlewareChain } from "./runtime-middleware.ts"; +import type { + CapabilityAuditEvent, + CapabilityAuditHook, + CapabilityEnvelope, + CapabilityErrorPayload, + CapabilityInputFor, + CapabilityModule, + CapabilityOutputFor, + ModuleRegistry, + PrachtAgentIdentity, + PrachtAgentsConfig, + PrachtApp, + PrachtCapability, + RegisteredCapabilityName, + ResolvedApiRoute, +} from "./types.ts"; + +export { CAPABILITY_HTTP_PREFIX, capabilityHttpPath }; + +/** Longest a capability may run before its signal aborts, matching API routes. */ +const CAPABILITY_TIMEOUT_MS = 30_000; + +/** Names must be URL-safe: dot-separated segments of [a-z0-9_-]. */ +const CAPABILITY_NAME_RE = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/i; + +export interface ResolvedCapability { + name: string; + file: string; + capability: PrachtCapability; + /** Dispatch path when `expose.http` is set, `null` for private capabilities. */ + httpPath: string | null; + middlewareFiles: string[]; +} + +export type CapabilityHostApp = Pick; + +// Resolution loads every registered capability module once per manifest + +// registry instance. Dev HMR can keep the same app manifest object while +// replacing the generated registry after a capability edit, so both identities +// participate in the cache key. +const resolvedCapabilitiesCache = new WeakMap< + object, + WeakMap> +>(); +const EMPTY_CAPABILITY_MODULES = {}; + +export function resolveAppCapabilities( + app: CapabilityHostApp, + registry: ModuleRegistry, +): Promise { + const capabilities = app.capabilities ?? {}; + const capabilityModules = registry.capabilityModules ?? EMPTY_CAPABILITY_MODULES; + let registryCache = resolvedCapabilitiesCache.get(capabilities); + if (!registryCache) { + registryCache = new WeakMap(); + resolvedCapabilitiesCache.set(capabilities, registryCache); + } + let resolved = registryCache.get(capabilityModules); + if (!resolved) { + resolved = resolveAppCapabilitiesUncached(app, registry); + registryCache.set(capabilityModules, resolved); + } + return resolved; +} + +async function resolveAppCapabilitiesUncached( + app: CapabilityHostApp, + registry: ModuleRegistry, +): Promise { + const resolved: ResolvedCapability[] = []; + const seenHttpPaths = new Map(); + + for (const [name, file] of Object.entries(app.capabilities ?? {})) { + if (!CAPABILITY_NAME_RE.test(name)) { + throw new Error( + `Invalid capability name "${name}". Names must be dot-separated segments of ` + + 'letters, numbers, hyphens, and underscores (e.g. "notes.search").', + ); + } + + const module = await resolveRegistryModule(registry.capabilityModules, file); + const capability = module?.default; + if (!capability || capability.kind !== "capability") { + throw new Error( + `Capability "${name}" (${file}) must default-export the result of ` + + "defineCapability() from @pracht/capabilities.", + ); + } + + // `defineCapability()` already refuses these; re-check here so a + // hand-rolled capability object fails closed before it can be served. + // Destructive + HTTP is allowed (the prepare/commit confirmation flow + // gates every dispatch); agent-initiated projections stay disallowed in v1. + if ( + capability.effect === "destructive" && + (capability.expose?.webmcp || capability.expose?.mcp) + ) { + throw new Error( + `Capability "${name}": destructive capabilities cannot be exposed to agent ` + + "projections (webmcp/mcp) yet — only expose.http with the confirmation flow.", + ); + } + if (capability.expose?.webmcp && !capability.expose.http) { + throw new Error(`Capability "${name}": expose.webmcp requires expose.http.`); + } + if ( + capability.expose && + (typeof capability.validateInput !== "function" || + typeof capability.validateOutput !== "function" || + typeof capability.description !== "string" || + !capability.input || + !capability.output || + !capability.effect) + ) { + throw new Error( + `Capability "${name}" is exposed but is missing its contract ` + + "(description, input schema, output schema, effect, validators).", + ); + } + + const middlewareFiles = (capability.middleware ?? []).map((middlewareName) => { + const middlewareFile = app.middleware?.[middlewareName]; + if (!middlewareFile) { + throw new Error( + formatUnknownNameError({ + kind: "middleware", + kindPlural: "middleware", + name: middlewareName, + registered: Object.keys(app.middleware ?? {}), + context: `capability "${name}"`, + }), + ); + } + return middlewareFile; + }); + + let httpPath: string | null = null; + if (capability.expose?.http) { + const configuredPath = capability.expose.http.path ?? capabilityHttpPath(name); + if (!isValidCapabilityHttpPath(configuredPath)) { + throw new Error( + `Capability "${name}": HTTP exposure path must be an exact same-origin pathname ` + + 'starting with "/".', + ); + } + httpPath = normalizeCapabilityHttpPath(configuredPath); + const existing = seenHttpPaths.get(httpPath); + if (existing) { + throw new Error( + `Capabilities "${existing}" and "${name}" both expose HTTP path "${httpPath}".`, + ); + } + seenHttpPaths.set(httpPath, name); + } + + resolved.push({ name, file, capability, httpPath, middlewareFiles }); + } + + return resolved; +} + +export function matchCapabilityRoute( + capabilities: readonly ResolvedCapability[], + pathname: string, +): ResolvedCapability | undefined { + const normalized = normalizeCapabilityHttpPath(pathname); + return capabilities.find((entry) => entry.httpPath === normalized); +} + +/** + * Best-effort path discovery used only after full registry resolution fails. + * It recognizes valid capability modules independently so custom HTTP paths + * still fail closed instead of falling through to an unrelated page route. + */ +export async function isRegisteredCapabilityHttpPath( + app: CapabilityHostApp, + registry: ModuleRegistry, + pathname: string, +): Promise { + const normalized = normalizeCapabilityHttpPath(pathname); + for (const [name, file] of Object.entries(app.capabilities ?? {})) { + try { + const module = await resolveRegistryModule( + registry.capabilityModules, + file, + ); + const capability = module?.default; + if (capability?.kind !== "capability" || !capability.expose?.http) continue; + const httpPath = normalizeCapabilityHttpPath( + capability.expose.http.path ?? capabilityHttpPath(name), + ); + if (httpPath === normalized) return true; + } catch { + // The full resolver reports the original error; this scan only identifies paths. + } + } + return false; +} + +interface CapabilityPipelineOptions { + resolved: ResolvedCapability; + input: unknown; + context: TContext; + registry: ModuleRegistry; + request: Request; + signal: AbortSignal; + url: URL; + /** Include internal error details (dev / direct server use). HTTP redacts in production. */ + exposeErrors: boolean; + /** + * Runs inside the middleware chain — after every named middleware, just + * before the capability body — so gating (e.g. the destructive prepare/commit + * confirmation flow) is subject to rate-limiting middleware. Returning an + * envelope short-circuits `run()`; `null`/absent proceeds. + */ + beforeRun?: (validatedInput: unknown) => Promise<{ + status: number; + envelope: CapabilityEnvelope; + } | null>; +} + +type CapabilityPipelineOutcome = + | { kind: "envelope"; status: number; envelope: CapabilityEnvelope; response: Response } + | { kind: "short-circuit"; response: Response }; + +/** + * Run one capability through validation, middleware, and execution. The + * middleware chain wraps the terminal exactly like page/API middleware does + * (same `runMiddlewareChain`), so `next()`, context mutation, and + * short-circuit semantics are identical everywhere. + */ +async function runCapabilityPipeline( + options: CapabilityPipelineOptions, +): Promise { + const { capability, name, file, httpPath, middlewareFiles } = options.resolved; + + const validatedInput = capability.validateInput(options.input); + if (!validatedInput.ok) { + const envelope = errorEnvelope({ + code: "invalid_input", + message: `Invalid input for capability "${name}".`, + issues: validatedInput.issues, + }); + return { kind: "envelope", status: 400, envelope, response: envelopeResponse(400, envelope) }; + } + + // Synthetic route descriptor handed to middleware, mirroring API dispatch. + const syntheticRoute: ResolvedApiRoute = { + path: httpPath ?? `capability:${name}`, + file, + segments: [], + }; + + // Holder object rather than a plain `let`: the value is assigned inside + // the terminal closure, which TypeScript's control-flow analysis cannot see. + const holder: { settled: { status: number; envelope: CapabilityEnvelope } | null } = { + settled: null, + }; + let terminalResponse: Response | null = null; + + const terminal = async (): Promise => { + if (options.beforeRun) { + const gate = await options.beforeRun(validatedInput.value); + if (gate) { + holder.settled = { status: gate.status, envelope: gate.envelope }; + terminalResponse = envelopeResponse(gate.status, gate.envelope); + return terminalResponse; + } + } + let output: unknown; + try { + output = await capability.run({ + input: validatedInput.value, + context: options.context, + request: options.request, + signal: options.signal, + }); + } catch (error: unknown) { + holder.settled = { + status: 500, + envelope: errorEnvelope({ + code: "internal_error", + message: options.exposeErrors + ? `Capability "${name}" failed: ${error instanceof Error ? error.message : String(error)}` + : "Capability failed.", + }), + }; + terminalResponse = envelopeResponse(holder.settled.status, holder.settled.envelope); + return terminalResponse; + } + + const validatedOutput = capability.validateOutput(output); + if (!validatedOutput.ok) { + // Invalid output is a server bug — never return the raw value. + holder.settled = { + status: 500, + envelope: errorEnvelope({ + code: "invalid_output", + message: options.exposeErrors + ? `Capability "${name}" produced output that does not match its output schema.` + : "Capability failed.", + issues: options.exposeErrors ? validatedOutput.issues : undefined, + }), + }; + terminalResponse = envelopeResponse(holder.settled.status, holder.settled.envelope); + return terminalResponse; + } + + holder.settled = { status: 200, envelope: { ok: true, data: validatedOutput.value } }; + terminalResponse = envelopeResponse(holder.settled.status, holder.settled.envelope); + return terminalResponse; + }; + + const response = await runMiddlewareChain({ + context: options.context, + middlewareFiles, + params: {}, + registry: options.registry, + request: options.request, + route: syntheticRoute, + signal: options.signal, + url: options.url, + terminal, + }); + + if ( + holder.settled && + (response === terminalResponse || (await responseMatchesEnvelope(response, holder.settled))) + ) { + return { kind: "envelope", ...holder.settled, response }; + } + return { kind: "short-circuit", response }; +} + +async function responseMatchesEnvelope( + response: Response, + settled: { status: number; envelope: CapabilityEnvelope }, +): Promise { + if (response.status !== settled.status) return false; + if (!response.headers.get("content-type")?.includes("application/json")) return false; + try { + return canonicalJson(await response.clone().json()) === canonicalJson(settled.envelope); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Audit trail +// --------------------------------------------------------------------------- + +// Module-level hook so server-only application code (middleware modules, +// capability modules, custom server entries) can subscribe without a way to +// pass functions through the serializable app manifest. Same registration +// style as `setActiveCapabilityHost`/`setIslandsClientEntryUrl`. +let capabilityAuditHook: CapabilityAuditHook | null = null; + +export function setCapabilityAuditHook(hook: CapabilityAuditHook | null): void { + capabilityAuditHook = hook; +} + +/** Audit hooks observe; they must never break a request. */ +function emitCapabilityAudit(event: CapabilityAuditEvent, extra?: CapabilityAuditHook): void { + for (const hook of [capabilityAuditHook, extra]) { + if (!hook) continue; + try { + hook(event); + } catch { + // Deliberately swallowed. + } + } +} + +export interface HandleCapabilityRequestOptions { + match: ResolvedCapability; + context: TContext; + registry: ModuleRegistry; + request: Request; + url: URL; + exposeErrors: boolean; + /** App-level agent trust config (`defineApp({ agents })`). */ + agents?: PrachtAgentsConfig; + /** Verified agent identity for this request, `null` when unsigned/unverified. */ + agent?: PrachtAgentIdentity | null; + onAudit?: CapabilityAuditHook; +} + +/** + * Handle a matched capability HTTP request. Method/CSRF checks already ran in + * `handlePrachtRequest`. Always answers with the typed envelope, except for + * middleware redirects (3xx pass through untouched). Emits one audit event + * per dispatch (principal, capability, effect, outcome, duration). + */ +export async function handleCapabilityRequest( + options: HandleCapabilityRequestOptions, +): Promise { + const started = performance.now(); + const { response, outcome } = await dispatchCapabilityHttp(options); + const responseWithEffect = withCapabilityEffect(response, options.match.capability.effect); + emitCapabilityAudit( + { + capability: options.match.name, + effect: options.match.capability.effect, + // The generated WebMCP shim marks its dispatches so audit trails can + // tell in-browser agent traffic (cookie-authenticated) apart from + // remote HTTP callers. Client-declared, so informational only. + transport: + options.request.headers.get(CAPABILITY_TRANSPORT_HEADER) === "webmcp" ? "webmcp" : "http", + outcome, + status: responseWithEffect.status, + durationMs: performance.now() - started, + agent: options.agent ?? null, + }, + options.onAudit, + ); + return responseWithEffect; +} + +function withCapabilityEffect(response: Response, effect: string): Response { + const headers = new Headers(response.headers); + headers.set(CAPABILITY_EFFECT_HEADER, effect); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +async function dispatchCapabilityHttp( + options: HandleCapabilityRequestOptions, +): Promise<{ response: Response; outcome: string }> { + const { capability, name } = options.match; + + if (options.request.method.toUpperCase() !== "POST") { + return audited( + envelopeResponse( + 405, + errorEnvelope({ + code: "method_not_allowed", + message: `Capability "${name}" only accepts POST.`, + }), + ), + "method_not_allowed", + ); + } + + // Web Bot Auth policy: per-capability override, then the app default. + // "require" without a verified agent fails closed with the 401 envelope — + // including when webBotAuth is not configured at all. + const policy = capability.agentPolicy ?? options.agents?.webBotAuth?.policy ?? "observe"; + if (policy === "require" && !options.agent) { + return audited( + envelopeResponse( + 401, + errorEnvelope({ + code: "agent_required", + message: `Capability "${name}" requires a verified agent signature (Web Bot Auth).`, + }), + ), + "agent_required", + ); + } + + // JSON is the native encoding. Form posts — the no-JS fallback of + // `` — are accepted too and coerced onto the input schema, + // so progressive enhancement hits the exact same contract as agents do. + let input: unknown = {}; + const contentType = options.request.headers.get("content-type") ?? ""; + const isFormPost = + contentType.includes("application/x-www-form-urlencoded") || + contentType.includes("multipart/form-data"); + if (isFormPost) { + try { + const form = await options.request.formData(); + input = coerceFormInput(capability.input, form.entries()); + } catch { + return audited( + envelopeResponse( + 400, + errorEnvelope({ + code: "invalid_json", + message: "Request form body could not be parsed.", + }), + ), + "invalid_json", + ); + } + } else { + try { + const body = await options.request.text(); + if (body.trim() !== "") { + input = JSON.parse(body); + } + } catch { + return audited( + envelopeResponse( + 400, + errorEnvelope({ code: "invalid_json", message: "Request body must be valid JSON." }), + ), + "invalid_json", + ); + } + } + + try { + // Destructive capabilities never run on the first call: the prepare/commit + // confirmation gate answers unless a valid token for this exact principal + + // input is presented. It runs as the pipeline's `beforeRun` hook — after + // named middleware — so rate-limiting middleware sees prepare and + // invalid-token attempts too. Invalid input still yields the usual 400 with + // issues because the pipeline validates before reaching the hook. + const beforeRun = + capability.effect === "destructive" + ? (validatedInput: unknown) => enforceDestructiveConfirmation(options, validatedInput) + : undefined; + + const outcome = await runCapabilityPipeline({ + resolved: options.match, + input, + context: options.context, + registry: options.registry, + request: options.request, + signal: AbortSignal.timeout(CAPABILITY_TIMEOUT_MS), + url: options.url, + exposeErrors: options.exposeErrors, + beforeRun, + }); + + if (outcome.kind === "envelope") { + // Progressive enhancement: a successful form post from a document (the + // no-JS `` fallback) navigates back to the page it + // was posted from instead of rendering the JSON envelope. + if ( + isFormPost && + outcome.envelope.ok && + (options.request.headers.get("accept") ?? "").includes("text/html") + ) { + const back = sameOriginReferer(options.request, options.url); + if (back) { + return audited(Response.redirect(back, 303), "ok"); + } + } + return audited(outcome.response, envelopeOutcome(outcome.envelope)); + } + const normalized = normalizeMiddlewareShortCircuit(outcome.response); + return audited(normalized, `middleware_${normalized.status}`); + } catch (error: unknown) { + return audited( + envelopeResponse( + 500, + errorEnvelope({ + code: "internal_error", + message: options.exposeErrors + ? `Capability "${name}" failed: ${error instanceof Error ? error.message : String(error)}` + : "Capability failed.", + }), + ), + "internal_error", + ); + } +} + +function audited(response: Response, outcome: string): { response: Response; outcome: string } { + return { response, outcome }; +} + +/** Referer of a document form post, only when it stays on this origin. */ +function sameOriginReferer(request: Request, url: URL): string | null { + const referer = request.headers.get("referer"); + if (!referer) return null; + try { + const parsed = new URL(referer); + return parsed.origin === url.origin ? parsed.href : null; + } catch { + return null; + } +} + +function envelopeOutcome(envelope: CapabilityEnvelope): string { + return envelope.ok ? "ok" : envelope.error.code; +} + +/** + * Prepare/commit gate for destructive capability HTTP calls. Returns the + * envelope ending the request, or `null` when a valid confirmation token was + * presented and the capability may run. Runs as the pipeline's `beforeRun` + * hook — i.e. after named middleware — so rate limiting sees prepare and + * invalid-token attempts too. See runtime-confirmation.ts for the token + * construction and its documented replay limitations. + */ +async function enforceDestructiveConfirmation( + options: HandleCapabilityRequestOptions, + validatedInput: unknown, +): Promise<{ status: number; envelope: CapabilityEnvelope } | null> { + const secret = resolveConfirmationSecret(); + if (!secret) { + // Exposed destructive capability without a configured secret: fail closed. + // `pracht verify` reports this at build time too. + return { + status: 403, + envelope: errorEnvelope({ + code: "confirmation_unavailable", + message: + `Destructive capability "${options.match.name}" cannot run: no confirmation ` + + `secret is configured (set ${CONFIRMATION_SECRET_ENV}).`, + }), + }; + } + + const binding = { + secret, + principal: options.agent ? `agent:${options.agent.keyId}` : "anonymous", + capability: options.match.name, + canonicalInput: canonicalJson(validatedInput), + }; + const presented = options.request.headers.get(CONFIRMATION_HEADER); + + if (!presented) { + const ttlSeconds = options.agents?.confirmation?.ttlSeconds ?? DEFAULT_CONFIRMATION_TTL_SECONDS; + const { token, expiresAt } = await createConfirmationToken({ ...binding, ttlSeconds }); + return { + status: 409, + envelope: errorEnvelope({ + code: "confirmation_required", + message: + `Capability "${options.match.name}" is destructive. Repeat the call with ` + + `identical input and the "${CONFIRMATION_HEADER}" header set to the confirmation token.`, + confirmationToken: token, + expiresAt, + }), + }; + } + + const verification = await verifyConfirmationToken(presented, binding); + if (!verification.ok) { + return { + status: 403, + envelope: errorEnvelope({ + code: "confirmation_invalid", + message: `Confirmation token rejected (${verification.reason}).`, + }), + }; + } + + if ( + options.agents?.confirmation?.singleUse && + !consumeConfirmationToken(verification.signature, verification.expiresAt) + ) { + return { + status: 403, + envelope: errorEnvelope({ + code: "confirmation_invalid", + message: "Confirmation token rejected (already_used).", + }), + }; + } + + return null; +} + +/** + * Middleware that returns without calling `next()` decides the response. + * Redirects and 2xx responses pass through untouched; error statuses are + * normalized into the envelope (status and headers preserved) so HTTP + * callers always receive the typed shape. + */ +function normalizeMiddlewareShortCircuit(response: Response): Response { + if (response.status < 400) { + return response; + } + + const code = + response.status === 401 + ? "unauthorized" + : response.status === 403 + ? "forbidden" + : "middleware_rejected"; + const headers = new Headers(response.headers); + headers.set("content-type", "application/json; charset=utf-8"); + headers.delete("content-length"); + return new Response( + JSON.stringify( + errorEnvelope({ + code, + message: `Request rejected by middleware (status ${response.status}).`, + }), + ), + { status: response.status, headers }, + ); +} + +// --------------------------------------------------------------------------- +// Direct server-side invocation +// --------------------------------------------------------------------------- + +export interface CapabilityHost { + app: CapabilityHostApp; + registry: ModuleRegistry; +} + +// Bind each host to the incoming Request rather than a process-global slot. +// Custom servers may serve multiple Pracht apps from one process, and dev HMR +// can replace a registry while an older request is still awaiting its loader. +// A WeakMap keeps those overlapping invocations isolated on every Web runtime +// without retaining completed requests. +const activeCapabilityHosts = new WeakMap(); + +export function setActiveCapabilityHost( + request: Request, + app: CapabilityHostApp, + registry: ModuleRegistry, +): void { + activeCapabilityHosts.set(request, { app, registry }); +} + +export interface InvokeCapabilityContext { + /** The incoming request — middleware and `run()` receive it. */ + request: Request; + context?: TContext; + signal?: AbortSignal; +} + +/** + * Invoke a registered capability directly from server code (loaders, API + * routes, middleware). Runs the exact same pipeline as the HTTP projection — + * input validation, the capability's named middleware, `run()`, output + * validation — and resolves to the same typed envelope. Works for private + * (non-exposed) capabilities too. + * + * When `pracht typegen` has registered the capability graph on + * `Register["capabilities"]`, the input and output types are inferred from + * the capability name; the explicit `invokeCapability(...)` form + * keeps working for unregistered names. + */ +export async function invokeCapability( + name: TName, + input: CapabilityInputFor, + ctx: InvokeCapabilityContext, +): Promise>>; +export async function invokeCapability( + name: string, + input: unknown, + ctx: InvokeCapabilityContext, +): Promise>; +export async function invokeCapability( + name: string, + input: unknown, + ctx: InvokeCapabilityContext, +): Promise> { + const host = activeCapabilityHosts.get(ctx.request); + if (!host) { + throw new Error( + "invokeCapability() has no capability host for this request. It is only available while " + + "handlePrachtRequest() is serving requests (loaders, API routes, middleware). " + + "In tests, build a standalone host with createCapabilityTestHost() instead.", + ); + } + return invokeCapabilityOnHost(host, name, input, ctx); +} + +/** + * Run one capability through the full dispatch pipeline against an explicit + * host. Shared by `invokeCapability()` (the request-bound host installed by + * `handlePrachtRequest`) and `createCapabilityTestHost()` (a synthetic host + * for tests). + */ +export async function invokeCapabilityOnHost( + host: CapabilityHost, + name: string, + input: unknown, + ctx: InvokeCapabilityContext, +): Promise> { + const capabilities = await resolveAppCapabilities(host.app, host.registry); + const resolved = capabilities.find((entry) => entry.name === name); + if (!resolved) { + return errorEnvelope({ + code: "unknown_capability", + message: formatUnknownNameError({ + kind: "capability", + kindPlural: "capabilities", + name, + registered: capabilities.map((entry) => entry.name), + }), + }) as CapabilityEnvelope; + } + + const started = performance.now(); + const context = ctx.context ?? {}; + let outcome: CapabilityPipelineOutcome; + try { + outcome = await runCapabilityPipeline({ + resolved, + input, + context, + registry: host.registry, + request: ctx.request, + signal: ctx.signal ?? AbortSignal.timeout(CAPABILITY_TIMEOUT_MS), + url: new URL(ctx.request.url), + // Direct invocation stays server-side, so real error messages are safe. + exposeErrors: true, + }); + } catch (error: unknown) { + // Middleware resolution/execution can throw (bad module, a middleware that + // does not return a Response). HTTP dispatch turns these into an + // internal_error envelope; direct invocation must honor the same + // envelope-returning contract rather than rejecting. + const envelope = errorEnvelope({ + code: "internal_error", + message: `Capability "${name}" failed: ${error instanceof Error ? error.message : String(error)}`, + }); + emitCapabilityAudit({ + capability: name, + effect: resolved.capability.effect, + transport: "server", + outcome: "internal_error", + status: 500, + durationMs: performance.now() - started, + agent: (context as { agent?: PrachtAgentIdentity | null }).agent ?? null, + }); + return envelope as CapabilityEnvelope; + } + + // Direct invocation audits like HTTP dispatch does, marked as the "server" + // transport. The agent identity travels on the request context when Web + // Bot Auth is enabled. + const agent = (context as { agent?: PrachtAgentIdentity | null }).agent ?? null; + const status = outcome.kind === "envelope" ? outcome.status : outcome.response.status; + const auditOutcome = + outcome.kind === "envelope" ? envelopeOutcome(outcome.envelope) : `middleware_${status}`; + emitCapabilityAudit({ + capability: name, + effect: resolved.capability.effect, + transport: "server", + outcome: auditOutcome, + status, + durationMs: performance.now() - started, + agent, + }); + + if (outcome.kind === "envelope") { + return outcome.envelope as CapabilityEnvelope; + } + + const code = + status === 401 + ? "unauthorized" + : status === 403 + ? "forbidden" + : status >= 300 && status < 400 + ? "redirect" + : "middleware_rejected"; + return errorEnvelope({ + code, + message: `Capability middleware short-circuited with status ${status}.`, + }) as CapabilityEnvelope; +} + +function errorEnvelope(error: CapabilityErrorPayload): CapabilityEnvelope { + return { ok: false, error }; +} + +export function envelopeResponse(status: number, envelope: CapabilityEnvelope): Response { + return new Response(JSON.stringify(envelope), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); +} diff --git a/packages/framework/src/runtime-confirmation.ts b/packages/framework/src/runtime-confirmation.ts new file mode 100644 index 00000000..d510ac32 --- /dev/null +++ b/packages/framework/src/runtime-confirmation.ts @@ -0,0 +1,231 @@ +/** + * Server-verified prepare/commit confirmation for destructive capabilities. + * + * A destructive capability exposed over HTTP never runs on the first call. + * The first (prepare) call returns a `confirmation_required` envelope with a + * short-lived token: an HMAC-SHA256 (WebCrypto) over the caller's principal, + * the capability name, a hash of the canonicalized (stable-JSON) validated + * input, and an expiry. The second (commit) call presents the token in the + * `x-pracht-confirm` header with byte-identical canonical input; anything + * else — tampering, expiry, different input, different principal — fails + * closed with 403. + * + * Honest limitation: a stateless HMAC cannot prevent replay *within* the TTL. + * True single-use requires shared storage; the optional in-memory cache below + * is best effort and per-instance only (documented in docs/AGENT_TRUST.md). + * + * The secret comes from `PRACHT_CONFIRMATION_SECRET` or + * `setCapabilityConfirmationSecret()` — never from the app manifest, which is + * bundled into the client. + */ + +import { CONFIRMATION_HEADER, CONFIRMATION_SECRET_ENV } from "@pracht/capabilities"; + +export { CONFIRMATION_HEADER, CONFIRMATION_SECRET_ENV }; +export const DEFAULT_CONFIRMATION_TTL_SECONDS = 120; + +const TOKEN_VERSION = "v1"; + +const encoder = new TextEncoder(); + +let programmaticSecret: string | null = null; + +/** + * Configure the confirmation secret at runtime — for platforms where + * `process.env` is unavailable (e.g. Cloudflare Workers without + * `nodejs_compat`). Takes precedence over the environment variable. + */ +export function setCapabilityConfirmationSecret(secret: string | null): void { + programmaticSecret = secret; +} + +export function resolveConfirmationSecret(): string | null { + if (programmaticSecret) return programmaticSecret; + const env = (globalThis as { process?: { env?: Record } }).process + ?.env; + const secret = env?.[CONFIRMATION_SECRET_ENV]; + return typeof secret === "string" && secret !== "" ? secret : null; +} + +/** + * Deterministic JSON with lexicographically sorted object keys, so the same + * logical input always canonicalizes to the same bytes regardless of the + * caller's property order. Input has already passed JSON.parse + schema + * validation, so only JSON-representable values reach this. + */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`); + return `{${entries.join(",")}}`; +} + +interface ConfirmationClaims { + /** Principal the token is bound to (verified agent key id, or "anonymous"). */ + p: string; + /** Capability name. */ + c: string; + /** Base64url SHA-256 of the canonicalized validated input. */ + i: string; + /** Unix seconds expiry. */ + exp: number; +} + +export interface ConfirmationBinding { + secret: string; + principal: string; + capability: string; + canonicalInput: string; + now?: number; +} + +export async function createConfirmationToken( + binding: ConfirmationBinding & { ttlSeconds: number }, +): Promise<{ token: string; expiresAt: number }> { + const now = binding.now ?? Math.floor(Date.now() / 1000); + const claims: ConfirmationClaims = { + p: binding.principal, + c: binding.capability, + i: await sha256Base64Url(binding.canonicalInput), + exp: now + binding.ttlSeconds, + }; + const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims))); + const signature = await hmacSign(binding.secret, `${TOKEN_VERSION}.${payload}`); + return { token: `${TOKEN_VERSION}.${payload}.${signature}`, expiresAt: claims.exp }; +} + +export type ConfirmationFailure = + | "malformed" + | "bad_signature" + | "expired" + | "principal_mismatch" + | "capability_mismatch" + | "input_mismatch" + | "already_used"; + +export type ConfirmationVerification = + | { ok: true; signature: string; expiresAt: number } + | { ok: false; reason: ConfirmationFailure }; + +/** + * Verify a presented confirmation token against the current call. The + * signature is checked first so nothing later in the pipeline trusts + * attacker-controlled claims. + */ +export async function verifyConfirmationToken( + token: string, + binding: ConfirmationBinding, +): Promise { + const parts = token.split("."); + if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) { + return { ok: false, reason: "malformed" }; + } + const [, payload, signature] = parts; + + const expected = await hmacSign(binding.secret, `${TOKEN_VERSION}.${payload}`); + if (!timingSafeEqual(signature, expected)) { + return { ok: false, reason: "bad_signature" }; + } + + let claims: ConfirmationClaims; + try { + claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as ConfirmationClaims; + } catch { + return { ok: false, reason: "malformed" }; + } + if (typeof claims.exp !== "number" || typeof claims.i !== "string") { + return { ok: false, reason: "malformed" }; + } + + const now = binding.now ?? Math.floor(Date.now() / 1000); + if (claims.exp < now) return { ok: false, reason: "expired" }; + if (claims.p !== binding.principal) return { ok: false, reason: "principal_mismatch" }; + if (claims.c !== binding.capability) return { ok: false, reason: "capability_mismatch" }; + if (claims.i !== (await sha256Base64Url(binding.canonicalInput))) { + return { ok: false, reason: "input_mismatch" }; + } + + return { ok: true, signature, expiresAt: claims.exp }; +} + +// --------------------------------------------------------------------------- +// Optional best-effort single-use cache (per-instance, in-memory) +// --------------------------------------------------------------------------- + +const usedTokens = new Map(); + +/** + * Mark a token as used. Returns false when it was already consumed on this + * instance. Expired entries are swept opportunistically so the map cannot + * grow past the confirmation TTL's working set. + */ +export function consumeConfirmationToken(signature: string, expiresAt: number): boolean { + const now = Math.floor(Date.now() / 1000); + if (usedTokens.size > 0) { + for (const [used, expiry] of usedTokens) { + if (expiry < now) usedTokens.delete(used); + } + } + if (usedTokens.has(signature)) return false; + usedTokens.set(signature, expiresAt); + return true; +} + +/** Test hook — clears the single-use cache. */ +export function clearConsumedConfirmationTokens(): void { + usedTokens.clear(); +} + +// --------------------------------------------------------------------------- +// WebCrypto helpers +// --------------------------------------------------------------------------- + +async function hmacSign(secret: string, message: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); + return base64UrlEncode(new Uint8Array(signature)); +} + +async function sha256Base64Url(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Constant-time comparison of two base64url strings. */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let index = 0; index < a.length; index += 1) { + mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + return mismatch === 0; +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64UrlDecode(value: string): Uint8Array { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} diff --git a/packages/framework/src/runtime-context.ts b/packages/framework/src/runtime-context.ts index 2da94d76..7085eb7e 100644 --- a/packages/framework/src/runtime-context.ts +++ b/packages/framework/src/runtime-context.ts @@ -1,8 +1,10 @@ +import { CAPABILITY_SETTLED_EVENT } from "@pracht/capabilities"; import { createContext, h } from "preact"; import type { ComponentChildren } from "preact"; import { useEffect, useMemo, useState } from "preact/hooks"; import { EMPTY_ROUTE_PARAMS, HYDRATION_STATE_ELEMENT_ID } from "./runtime-constants.ts"; +import { revalidateRouteData, shouldRevalidateAfterCapability } from "./runtime-revalidate.ts"; import type { HrefRouteDefinition, RouteParams } from "./types.ts"; export interface PrachtHydrationState { @@ -84,6 +86,21 @@ export function PrachtRuntimeProvider({ [routeData, params, routeId, routes, stateVersion, url], ); + // Effect-driven revalidation: capabilities are effect-classed, so the + // runtime can refresh route data after any successful non-`read` call made + // from the browser — `callCapability()` and `` announce + // themselves via CAPABILITY_SETTLED_EVENT instead of importing the router. + useEffect(() => { + const handleSettled = (event: Event) => { + if (!shouldRevalidateAfterCapability((event as CustomEvent).detail)) return; + void revalidateRouteData(context).catch(() => { + // Revalidation is best-effort; the mutation itself already succeeded. + }); + }; + window.addEventListener(CAPABILITY_SETTLED_EVENT, handleSettled); + return () => window.removeEventListener(CAPABILITY_SETTLED_EVENT, handleSettled); + }, [context]); + return h(RouteDataContext.Provider, { value: context, children, diff --git a/packages/framework/src/runtime-headers.ts b/packages/framework/src/runtime-headers.ts index 018d9568..c3628c14 100644 --- a/packages/framework/src/runtime-headers.ts +++ b/packages/framework/src/runtime-headers.ts @@ -1,3 +1,8 @@ +import { + CAPABILITY_FORM_REDIRECT_HEADER, + CAPABILITY_FORM_REQUEST_HEADER, +} from "@pracht/capabilities"; + import { ROUTE_STATE_CACHE_CONTROL, ROUTE_STATE_REQUEST_HEADER } from "./runtime-constants.ts"; import type { LoaderCache } from "./types.ts"; @@ -95,6 +100,42 @@ export function withDefaultSecurityHeaders(response: Response): Response { }); } +/** + * Keep enhanced capability-form redirects inside the original same-origin + * fetch. The client performs the browser navigation after reading the target, + * so cross-origin login pages are never fetched through CORS. + */ +export function withEnhancedCapabilityFormRedirect(response: Response, request: Request): Response { + if (request.headers.get(CAPABILITY_FORM_REQUEST_HEADER) !== "1") { + return response; + } + if (response.status < 300 || response.status >= 400) { + return response; + } + const location = response.headers.get("location"); + if (!location) { + return response; + } + + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("location"); + // Fetch resolves a relative Location against the response URL. Preserve + // those semantics when moving the target into the enhanced-form handshake + // instead of letting the client resolve it against the current page. + let redirectTarget = location; + try { + redirectTarget = new URL(location, request.url).toString(); + } catch { + // The client applies the shared safe-navigation check before using the + // target, so keep an unparseable value for it to reject explicitly. + } + headers.set(CAPABILITY_FORM_REDIRECT_HEADER, redirectTarget); + headers.set("cache-control", "no-store"); + appendVaryHeader(headers, CAPABILITY_FORM_REQUEST_HEADER); + return new Response(null, { status: 204, headers }); +} + export function withRouteResponseHeaders( response: Response, options: { isRouteStateRequest: boolean; loaderCache?: LoaderCache }, diff --git a/packages/framework/src/runtime-hooks.ts b/packages/framework/src/runtime-hooks.ts index 5497a30b..0205faba 100644 --- a/packages/framework/src/runtime-hooks.ts +++ b/packages/framework/src/runtime-hooks.ts @@ -32,14 +32,24 @@ import { type PrachtHydrationState, type StartAppOptions, } from "./runtime-context.ts"; +import { + CAPABILITY_EFFECT_HEADER, + CAPABILITY_FORM_REDIRECT_HEADER, + CAPABILITY_FORM_REQUEST_HEADER, + CAPABILITY_SETTLED_EVENT, + capabilityHttpPath, +} from "@pracht/capabilities"; import { clearPrefetchCache } from "./prefetch-cache.ts"; -import { deserializeRouteError } from "./runtime-errors.ts"; -import { fetchPrachtRouteState, navigateToClientLocation } from "./runtime-client-fetch.ts"; +import { navigateToClientLocation, parseSafeNavigationUrl } from "./runtime-client-fetch.ts"; +import { revalidateRouteData } from "./runtime-revalidate.ts"; import type { ApiPath, + CapabilityEnvelope, + CapabilityOutputFor, LinkPrefetchStrategy, LoaderData, LoaderLike, + RegisteredCapabilityName, RouteDataFor, RouteId, RouteParams, @@ -50,7 +60,15 @@ export { PrachtRuntimeProvider, readHydrationState, startApp }; export type { PrachtHydrationState, StartAppOptions }; export type { Navigation, NavigationLocation } from "./navigation-state.ts"; -export interface FormProps extends Omit, "action" | "method"> { +/** Envelope data type for a capability name, when typegen has registered it. */ +type CapabilityFormResult = TName extends RegisteredCapabilityName + ? CapabilityEnvelope> + : CapabilityEnvelope; + +export interface FormProps extends Omit< + JSX.HTMLAttributes, + "action" | "method" +> { /** * Form action. Autocompletes API route paths registered by `pracht typegen` * while still accepting any URL string (dynamic segments must be @@ -58,6 +76,19 @@ export interface FormProps extends Omit, "ac */ action?: ApiPath | (string & {}); method?: string; + /** + * Post this form to a capability's HTTP projection instead of an `action` + * URL — the same endpoint agents call, so the human form and the agent + * tool literally share one contract. Fields are coerced onto the + * capability's input schema server-side; after a successful submission the + * active route's data revalidates automatically. Works without JavaScript: + * the endpoint accepts the form-encoded fallback and redirects back to the + * page. Set `action` explicitly for capabilities with a custom + * `expose.http.path`. + */ + capability?: TName; + /** Called with the typed envelope after a `capability` submission settles. */ + onCapabilityResult?: (envelope: CapabilityFormResult) => void; /** * Standard Schema validated against the form's data (one entry per field, * arrays for repeated fields) before submitting. When validation fails the @@ -135,26 +166,7 @@ export function useParams(): RouteParams { export function useRevalidate() { const runtime = useContext(RouteDataContext); - return async () => { - if (typeof window === "undefined") { - return undefined; - } - - const path = runtime?.url || window.location.pathname + window.location.search; - const result = await fetchPrachtRouteState(path, { cache: "reload" }); - - if (result.type === "redirect") { - await navigateToClientLocation(result.location); - return undefined; - } - - if (result.type === "error") { - throw deserializeRouteError(result.error); - } - - runtime?.setData(result.data); - return result.data; - }; + return () => revalidateRouteData(runtime); } /** @@ -201,12 +213,27 @@ export function Link(props: LinkProps) { } as JSX.HTMLAttributes); } -export function Form(props: FormProps) { - const { onSubmit, method, schema, onValidationIssues, onResponse, ...rest } = props; +export function Form(props: FormProps) { + const { + onSubmit, + method, + action, + capability, + onCapabilityResult, + schema, + onValidationIssues, + onResponse, + ...rest + } = props; + // Capability forms post to the capability's HTTP projection; the action + // attribute keeps the no-JS fallback working (the endpoint accepts + // form-encoded bodies and redirects document posts back on success). + const actionAttribute = capability ? (action ?? capabilityHttpPath(capability)) : action; return h("form", { ...rest, - method, + method: capability ? "post" : method, + action: actionAttribute, onSubmit: async (event: Event) => { const form = event.currentTarget; if (!(form instanceof HTMLFormElement)) { @@ -228,6 +255,115 @@ export function Form(props: FormProps) { submitter.form === form ? submitter : undefined; + + if (capability) { + const submitterAction = nativeSubmitter?.getAttribute("formaction"); + const endpoint = submitterAction ?? actionAttribute ?? form.action; + const endpointUrl = parseSafeNavigationUrl(endpoint, window.location.href); + if (!endpointUrl) { + event.preventDefault(); + console.error(`[pracht] refused to submit capability form to unsafe URL: ${endpoint}`); + return; + } + const isCrossOriginEndpoint = endpointUrl.origin !== window.location.origin; + // A cross-origin form target cannot participate in the enhanced + // response handshake. Let the browser perform the native submission + // so redirects and authentication flows retain document semantics. + if (isCrossOriginEndpoint && !schema) { + return; + } + event.preventDefault(); + const formData = new FormData(form, nativeSubmitter); + + if (schema) { + const result = await validateStandardSchema(schema, formDataToRecord(formData), "body"); + if (result.issues) { + onValidationIssues?.(result.issues); + return; + } + } + if (isCrossOriginEndpoint) { + validatedNativeSubmissions.add(form); + try { + form.requestSubmit(nativeSubmitter); + } finally { + validatedNativeSubmissions.delete(form); + } + return; + } + + clearPrefetchCache(); + // Expose the in-flight submission through useNavigation(). + const navigationToken = beginSubmittingNavigation( + createNavigationLocation(endpoint), + formData, + ); + let envelope: CapabilityEnvelope; + let response: Response | undefined; + try { + response = await fetch(endpoint, { + method: "POST", + body: formData, + credentials: "same-origin", + headers: { [CAPABILITY_FORM_REQUEST_HEADER]: "1" }, + }); + const enhancedRedirect = response.headers.get(CAPABILITY_FORM_REDIRECT_HEADER); + if ( + enhancedRedirect || + response.redirected || + (response.status >= 300 && response.status < 400) + ) { + const location = + enhancedRedirect ?? + (response.redirected ? response.url : response.headers.get("location")); + await navigateToClientLocation(location ?? endpoint, { reloadRouteState: true }); + return; + } + try { + envelope = (await response.clone().json()) as CapabilityEnvelope; + } catch { + envelope = { + ok: false, + error: { + code: "invalid_response", + message: `Capability endpoint returned a non-JSON response (status ${response.status}).`, + }, + }; + } + } catch (error: unknown) { + envelope = { + ok: false, + error: { + code: "network_error", + message: error instanceof Error ? error.message : String(error), + }, + }; + } finally { + settleNavigation(navigationToken); + } + + if (response) { + onResponse?.(response); + } + if (envelope.ok) { + form.reset(); + } + // The runtime provider revalidates route data on this event. The + // server returns the matched capability's effect class so read-only + // form submissions avoid invalidating the active route. + window.dispatchEvent( + new CustomEvent(CAPABILITY_SETTLED_EVENT, { + detail: { + name: capability, + ok: envelope.ok, + effect: response?.headers.get(CAPABILITY_EFFECT_HEADER) ?? null, + }, + }), + ); + onCapabilityResult?.(envelope as CapabilityFormResult); + return; + } + const submitterMethod = nativeSubmitter?.getAttribute("formmethod") || undefined; const formMethod = (submitterMethod ?? method ?? form.method ?? "post").toUpperCase(); const isSafeMethod = SAFE_METHODS.has(formMethod); @@ -237,7 +373,7 @@ export function Form(props: FormProps) { event.preventDefault(); const submitterAction = nativeSubmitter?.getAttribute("formaction"); - const actionUrl = submitterAction ?? props.action ?? form.action; + const actionUrl = submitterAction ?? action ?? form.action; const formData = new FormData(form, nativeSubmitter); if (schema) { diff --git a/packages/framework/src/runtime-revalidate.ts b/packages/framework/src/runtime-revalidate.ts new file mode 100644 index 00000000..d7e5e045 --- /dev/null +++ b/packages/framework/src/runtime-revalidate.ts @@ -0,0 +1,52 @@ +import { deserializeRouteError } from "./runtime-errors.ts"; +import { fetchPrachtRouteState, navigateToClientLocation } from "./runtime-client-fetch.ts"; +import type { PrachtRuntimeValue } from "./runtime-context.ts"; + +/** + * Re-fetch the active route's loader data and commit it to the runtime. + * Shared by `useRevalidate()`, `` submissions, and the + * capability-settled listener in the runtime provider, so every mutation + * path refreshes the page the same way. + */ +export async function revalidateRouteData( + runtime: PrachtRuntimeValue | undefined, +): Promise { + if (typeof window === "undefined") { + return undefined; + } + + const path = runtime?.url || window.location.pathname + window.location.search; + const result = await fetchPrachtRouteState(path, { cache: "reload" }); + + if (result.type === "redirect") { + await navigateToClientLocation(result.location); + return undefined; + } + + if (result.type === "error") { + throw deserializeRouteError(result.error); + } + + runtime?.setData(result.data); + return result.data; +} + +/** + * Detail shape of the CAPABILITY_SETTLED_EVENT window event. `effect` and + * `revalidate` may be absent when an older or non-Pracht dispatcher doesn't + * know them; current generated clients and `` provide the + * effect class. + */ +export interface CapabilitySettledDetail { + name: string; + ok: boolean; + effect?: string | null; + revalidate?: boolean; +} + +/** A settled capability call refreshes route data unless it was a read, failed, or opted out. */ +export function shouldRevalidateAfterCapability(detail: unknown): boolean { + if (!detail || typeof detail !== "object") return false; + const settled = detail as CapabilitySettledDetail; + return settled.ok === true && settled.effect !== "read" && settled.revalidate !== false; +} diff --git a/packages/framework/src/runtime.ts b/packages/framework/src/runtime.ts index 151e6d0d..5ca1af78 100644 --- a/packages/framework/src/runtime.ts +++ b/packages/framework/src/runtime.ts @@ -10,7 +10,11 @@ import { shouldExposeServerErrors, type PrachtRuntimeDiagnosticPhase, } from "./runtime-errors.ts"; -import { appendVaryHeader, withDefaultSecurityHeaders } from "./runtime-headers.ts"; +import { + appendVaryHeader, + withDefaultSecurityHeaders, + withEnhancedCapabilityFormRedirect, +} from "./runtime-headers.ts"; import { PrachtRuntimeProvider } from "./runtime-context.ts"; import { buildHtmlDocument, htmlResponse } from "./runtime-html.ts"; import { getAppSpeculationRules } from "./runtime-speculation.ts"; @@ -34,6 +38,17 @@ import { mergeHeadMetadata, runMiddlewareChain, } from "./runtime-middleware.ts"; +import { + CAPABILITY_HTTP_PREFIX, + envelopeResponse, + handleCapabilityRequest, + isRegisteredCapabilityHttpPath, + matchCapabilityRoute, + resolveAppCapabilities, + setActiveCapabilityHost, + type ResolvedCapability, +} from "./runtime-capabilities.ts"; +import { verifyAgentSignature } from "./runtime-agent-auth.ts"; import { buildRouteStateUrl } from "./runtime-client-fetch.ts"; import { getRenderToStringAsync, @@ -49,10 +64,13 @@ import type { ApiRouteArgs, ApiRouteModule, BaseRouteArgs, + CapabilityAuditHook, HttpMethod, ModuleRegistry, HrefRouteDefinition, + PrachtAgentIdentity, PrachtApp, + PrachtContextExtensions, ResolvedApiRoute, ResolvedPrachtApp, RouteMatch, @@ -70,7 +88,7 @@ const SAME_ORIGIN_FETCH_SITE = "same-origin"; * sibling subdomains can be attacker-controlled. Requests with no browser * provenance headers are treated as non-browser callers. */ -function isSameOriginMutation(request: Request, url: URL): boolean { +function isSameOriginRequest(request: Request, url: URL): boolean { const site = request.headers.get("sec-fetch-site"); if (site && site !== SAME_ORIGIN_FETCH_SITE) { return false; @@ -178,6 +196,13 @@ export interface HandlePrachtRequestOptions { * `Server-Timing` header. Leave unset in production — no timing work runs. */ timings?: PrachtPhaseTimings; + /** + * Structured audit callback invoked for every capability dispatch + * (principal/agent, capability, effect, outcome, duration). Custom server + * entries can pass it here; application code can alternatively register a + * hook via `setCapabilityAuditHook()` from any server-only module. + */ + onCapabilityAudit?: CapabilityAuditHook; } export async function handlePrachtRequest( @@ -204,6 +229,26 @@ export async function handlePrachtRequest( const isRouteStateRequest = headerSignalsRouteState || dataParamIsFirstParty; const exposeDiagnostics = shouldExposeServerErrors(options); + // Register the capability host so `invokeCapability()` works from loaders, + // API routes, and middleware during this request. One assignment — free + // for apps without capabilities. + setActiveCapabilityHost(options.request, options.app, registry); + + // Web Bot Auth: verify the agent signature once per request when the app + // opted in via `defineApp({ agents: { webBotAuth } })`. The result (identity + // or null) lands on the shared request context before middleware, loaders, + // API routes, or capabilities run. Apps without the config skip everything — + // a single property check. + const requestContext = (options.context ?? {}) as TContext; + const webBotAuth = options.app.agents?.webBotAuth; + let agent: PrachtAgentIdentity | null = null; + if (webBotAuth) { + if (options.request.headers.has("signature-input")) { + agent = await verifyAgentSignature(options.request, webBotAuth); + } + (requestContext as PrachtContextExtensions).agent = agent; + } + if (options.apiRoutes?.length) { const apiMatch = matchApiRoute(options.apiRoutes, url.pathname); if (apiMatch) { @@ -217,7 +262,7 @@ export async function handlePrachtRequest( if ( requireSameOrigin && !SAFE_METHODS.has(options.request.method) && - !isSameOriginMutation(options.request, url) + !isSameOriginRequest(options.request, url) ) { return withDefaultSecurityHeaders( new Response("Cross-origin request blocked", { @@ -228,7 +273,7 @@ export async function handlePrachtRequest( } const requestSignal = AbortSignal.timeout(30_000); - const apiContext = (options.context ?? {}) as TContext; + const apiContext = requestContext; const apiTerminal = async (): Promise => { currentPhase = "api"; @@ -275,7 +320,9 @@ export async function handlePrachtRequest( url, terminal: apiTerminal, }); - return withDefaultSecurityHeaders(response); + return withDefaultSecurityHeaders( + withEnhancedCapabilityFormRedirect(response, options.request), + ); } catch (error: unknown) { return renderApiErrorResponse({ error, @@ -288,6 +335,87 @@ export async function handlePrachtRequest( } } + // Capability HTTP projection. Only runs when the manifest registers + // capabilities — apps without them pay a single `Object.keys` call. + // Explicit API route files take precedence (they matched above). + if (Object.keys(options.app.capabilities ?? {}).length > 0) { + let capabilities: ResolvedCapability[] | null = null; + try { + capabilities = await resolveAppCapabilities(options.app, registry); + } catch (error: unknown) { + warnCapabilityResolutionFailure(error); + // A broken capability definition must not take down page rendering; + // requests to capability paths still fail closed below. + if ( + url.pathname.startsWith(CAPABILITY_HTTP_PREFIX) || + (await isRegisteredCapabilityHttpPath(options.app, registry, url.pathname)) + ) { + return withDefaultSecurityHeaders( + envelopeResponse(500, { + ok: false, + error: { + code: "internal_error", + message: exposeDiagnostics + ? `Capability registry failed to resolve: ${error instanceof Error ? error.message : String(error)}` + : "Capability registry failed to resolve.", + }, + }), + ); + } + } + + if (capabilities) { + const capabilityMatch = matchCapabilityRoute(capabilities, url.pathname); + if (capabilityMatch) { + // Same CSRF stance as state-changing API requests: capability calls + // are session-authenticated POSTs, so cross-origin browser requests + // are rejected unless the app opted out. + const requireSameOrigin = options.app.api?.requireSameOrigin ?? true; + if ( + requireSameOrigin && + !SAFE_METHODS.has(options.request.method) && + !isSameOriginRequest(options.request, url) + ) { + return withDefaultSecurityHeaders( + envelopeResponse(403, { + ok: false, + error: { code: "cross_origin_blocked", message: "Cross-origin request blocked" }, + }), + ); + } + + const capabilityResponse = await handleCapabilityRequest({ + match: capabilityMatch, + context: requestContext, + registry, + request: options.request, + url, + exposeErrors: exposeDiagnostics, + agents: options.app.agents, + agent, + onAudit: options.onCapabilityAudit, + }); + return withDefaultSecurityHeaders( + withEnhancedCapabilityFormRedirect(capabilityResponse, options.request), + ); + } + + // Unmatched requests under the capability prefix get the typed 404 + // instead of falling through to the HTML not-found page. + if (url.pathname.startsWith(CAPABILITY_HTTP_PREFIX)) { + return withDefaultSecurityHeaders( + envelopeResponse(404, { + ok: false, + error: { + code: "unknown_capability", + message: "No capability is exposed at this path.", + }, + }), + ); + } + } + } + const match = matchAppRoute(resolvedApp, url.pathname); if (!match) { @@ -366,7 +494,7 @@ export async function handlePrachtRequest( pageOptions: { isNotFoundPage: boolean; status: number }, ): Promise { const requestSignal = AbortSignal.timeout(30_000); - const pageContext = (options.context ?? {}) as TContext; + const pageContext = requestContext; const routeArgs: BaseRouteArgs = { request: options.request, params: match.params, @@ -751,6 +879,18 @@ function isNotFoundError(error: unknown): boolean { return isPrachtHttpError(error) && error.status === 404; } +let warnedCapabilityResolutionFailure = false; + +/** Resolution failures repeat on every request — log the details once. */ +function warnCapabilityResolutionFailure(error: unknown): void { + if (warnedCapabilityResolutionFailure) return; + warnedCapabilityResolutionFailure = true; + console.error( + "[pracht] Capability registry failed to resolve; capability requests will return 500:", + error, + ); +} + function getRequestPath(url: URL): string { return `${url.pathname}${url.search}`; } diff --git a/packages/framework/src/server.ts b/packages/framework/src/server.ts index a91d6b1c..22eb55fc 100644 --- a/packages/framework/src/server.ts +++ b/packages/framework/src/server.ts @@ -52,14 +52,33 @@ export { detectApiMethods, serializeApiRoutes, serializeAppRoutes, + serializeCapabilities, } from "./app-graph.ts"; export type { ApiRouteExports, AppGraph, AppGraphApiRoute, + AppGraphCapability, AppGraphModuleAccess, AppGraphRoute, } from "./app-graph.ts"; +export { + capabilityHttpPath, + invokeCapability, + matchCapabilityRoute, + resolveAppCapabilities, +} from "./runtime-capabilities.ts"; +export type { InvokeCapabilityContext, ResolvedCapability } from "./runtime-capabilities.ts"; +export { resolveRegistryModule } from "./runtime-manifest.ts"; +export { createCapabilityTestHost } from "./testing-capabilities.ts"; +export type { + CapabilityTestHost, + CapabilityTestHostOptions, + CapabilityTestInvokeOptions, + CapabilityTestRequestOptions, +} from "./testing-capabilities.ts"; +export { buildLlmsTxt } from "./llms-txt.ts"; +export type { BuildLlmsTxtOptions, LlmsTxtSection } from "./llms-txt.ts"; export { prerenderApp } from "./prerender.ts"; export { createISGRegenerationRequest, @@ -98,6 +117,20 @@ export type { ApiRouteMatch, ApiRouteModule, BaseRouteArgs, + CapabilityContext, + CapabilityEffect, + CapabilityEnvelope, + CapabilityErrorCode, + CapabilityErrorPayload, + CapabilityExposure, + CapabilityHttpExposure, + CapabilityIssue, + CapabilityModule, + CapabilityRunArgs, + CapabilityValidationResult, + PrachtCapability, + PrachtContextExtensions, + PrachtRequestContext, DataModule, ErrorBoundaryProps, GroupDefinition, diff --git a/packages/framework/src/testing-capabilities.ts b/packages/framework/src/testing-capabilities.ts new file mode 100644 index 00000000..30422e1f --- /dev/null +++ b/packages/framework/src/testing-capabilities.ts @@ -0,0 +1,181 @@ +/** + * Standalone capability test host. + * + * `invokeCapability()` needs the process-level host that `handlePrachtRequest` + * installs, so the full dispatch pipeline is normally only reachable through a + * running server. `createCapabilityTestHost()` builds that host synthetically — + * from capability objects and middleware functions, no manifest files or Vite — + * so unit tests can exercise the exact production code paths: + * + * - `invoke()` — the direct server projection (`invokeCapability`): input + * validation → middleware chain → run() → output validation, resolving to + * the typed envelope and emitting the same audit events. + * - `request()` — the HTTP projection (`handleCapabilityRequest`): everything + * above plus exposure/404 semantics, Web Bot Auth policy, and the + * destructive prepare/commit confirmation flow. A simulated verified agent + * identity can be injected via the `agent` option — no RFC 9421 signing + * required. + * + * The confirmation flow reads its secret from `PRACHT_CONFIRMATION_SECRET` or + * `setCapabilityConfirmationSecret()` — set one of them in test setup before + * exercising destructive capabilities. + */ + +import { formatUnknownNameError } from "./name-suggestions.ts"; +import { + handleCapabilityRequest, + invokeCapabilityOnHost, + resolveAppCapabilities, + type CapabilityHost, +} from "./runtime-capabilities.ts"; +import type { + CapabilityEnvelope, + CapabilityInputFor, + CapabilityOutputFor, + MiddlewareFn, + ModuleRegistry, + PrachtAgentIdentity, + PrachtAgentsConfig, + PrachtCapability, + RegisteredCapabilityName, +} from "./types.ts"; + +const TEST_ORIGIN = "http://capability-test.local"; + +export interface CapabilityTestHostOptions { + /** Capability name → the object `defineCapability()` returns. */ + capabilities: Record; + /** Middleware name → function, for capabilities declaring `middleware: [name]`. */ + middleware?: Record; + /** App-level agent trust config — the `defineApp({ agents })` equivalent. */ + agents?: PrachtAgentsConfig; +} + +export interface CapabilityTestInvokeOptions { + request?: Request; + context?: Record; + signal?: AbortSignal; +} + +export interface CapabilityTestRequestOptions { + /** Extra request headers, e.g. `{ "x-pracht-confirm": token }`. */ + headers?: HeadersInit; + context?: Record; + /** + * Simulated verified Web Bot Auth identity. Drives `agentPolicy` checks, + * the confirmation-token principal, audit events, and `context.agent` — + * exactly as if the request carried a valid signature. + */ + agent?: PrachtAgentIdentity | null; +} + +export interface CapabilityTestHost { + /** Direct server invocation — same pipeline and envelope as `invokeCapability()`. */ + invoke( + name: TName, + input: CapabilityInputFor, + options?: CapabilityTestInvokeOptions, + ): Promise>>; + invoke( + name: string, + input: unknown, + options?: CapabilityTestInvokeOptions, + ): Promise>; + /** HTTP dispatch — same handler the generated `/api/capabilities/*` endpoints use. */ + request(name: string, input: unknown, options?: CapabilityTestRequestOptions): Promise; +} + +export function createCapabilityTestHost(options: CapabilityTestHostOptions): CapabilityTestHost { + const capabilityFiles: Record = {}; + const capabilityModules: NonNullable = {}; + for (const [name, capability] of Object.entries(options.capabilities)) { + const file = `test:capability:${name}`; + capabilityFiles[name] = file; + capabilityModules[file] = async () => ({ default: capability }); + } + + const middlewareFiles: Record = {}; + const middlewareModules: NonNullable = {}; + for (const [name, middleware] of Object.entries(options.middleware ?? {})) { + const file = `test:middleware:${name}`; + middlewareFiles[name] = file; + middlewareModules[file] = async () => ({ middleware }); + } + + const host: CapabilityHost = { + app: { capabilities: capabilityFiles, middleware: middlewareFiles }, + registry: { capabilityModules, middlewareModules }, + }; + + return { + invoke( + name: string, + input: unknown, + invokeOptions: CapabilityTestInvokeOptions = {}, + ): Promise> { + return invokeCapabilityOnHost(host, name, input, { + request: invokeOptions.request ?? new Request(`${TEST_ORIGIN}/`), + context: invokeOptions.context ?? {}, + signal: invokeOptions.signal, + }); + }, + + async request( + name: string, + input: unknown, + requestOptions: CapabilityTestRequestOptions = {}, + ): Promise { + const capabilities = await resolveAppCapabilities(host.app, host.registry); + const match = capabilities.find((entry) => entry.name === name); + + // Mirror the wire: names that are not registered — or registered without + // `expose.http`, so no dispatch path exists — answer with the typed 404. + if (!match?.httpPath) { + return Response.json( + { + ok: false, + error: { + code: "unknown_capability", + message: formatUnknownNameError({ + kind: "capability", + kindPlural: "capabilities", + name, + registered: capabilities + .filter((entry) => entry.httpPath) + .map((entry) => entry.name), + }), + }, + }, + { status: 404 }, + ); + } + + const agent = requestOptions.agent ?? null; + const context: Record = { ...requestOptions.context }; + // `handlePrachtRequest` surfaces the verified identity on the request + // context before dispatch; simulated identities travel the same way. + if (options.agents?.webBotAuth || requestOptions.agent !== undefined) context.agent = agent; + + const headers = new Headers(requestOptions.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const request = new Request(`${TEST_ORIGIN}${match.httpPath}`, { + method: "POST", + headers, + body: JSON.stringify(input === undefined ? {} : input), + }); + + return handleCapabilityRequest({ + match, + context, + registry: host.registry, + request, + url: new URL(request.url), + exposeErrors: true, + agents: options.agents, + agent, + }); + }, + }; +} diff --git a/packages/framework/src/types.ts b/packages/framework/src/types.ts index 6df58e36..e4b2f7a4 100644 --- a/packages/framework/src/types.ts +++ b/packages/framework/src/types.ts @@ -1,3 +1,9 @@ +import type { + Capability, + CapabilityAgentPolicy, + CapabilityEffect, + PrachtAgentIdentity, +} from "@pracht/capabilities"; import type { ComponentChildren, FunctionComponent } from "preact"; import type { RouteConstraint } from "./constraints.ts"; @@ -19,7 +25,29 @@ import type { RouteConstraint } from "./constraints.ts"; // biome-ignore lint/suspicious/noEmptyInterface: augmented by users export interface Register {} -export type RegisteredContext = Register extends { context: infer T } ? T : unknown; +/** + * Fields the framework itself surfaces on the request context, merged into + * the app-registered context type so loaders, middleware, API routes, and + * capabilities all see them without casts. + */ +export interface PrachtContextExtensions { + /** + * Verified agent identity (Web Bot Auth); `null` when the request is + * unsigned or fails verification, absent when `defineApp({ agents })` is + * not configured. + */ + agent?: PrachtAgentIdentity | null; +} + +export type RegisteredContext = (Register extends { context: infer T } ? T : unknown) & + PrachtContextExtensions; + +/** + * The request context as application code receives it — the registered + * context plus the framework-surfaced fields. Use it to type standalone + * functions (e.g. the third `defineCapability()` generic). + */ +export type PrachtRequestContext = RegisteredContext; export type RenderMode = "spa" | "ssr" | "ssg" | "isg"; @@ -545,9 +573,112 @@ export interface GroupDefinition { export type RouteTreeNode = RouteDefinition | GroupDefinition; +// --------------------------------------------------------------------------- +// Agent trust layer (Web Bot Auth + destructive-capability confirmation) +// +// Everything in `agents` is plain serializable data — the app manifest is +// bundled into the client too, so no secrets and no functions belong here. +// Web Bot Auth keys are *public* Ed25519 keys; the confirmation secret comes +// from the environment (PRACHT_CONFIRMATION_SECRET) or +// `setCapabilityConfirmationSecret()`, never from the manifest. +// --------------------------------------------------------------------------- + +export type AgentPolicyMode = CapabilityAgentPolicy; + +/** A statically configured agent verification key (public Ed25519 JWK material). */ +export interface WebBotAuthStaticKey { + /** Base64url raw Ed25519 public key — the JWK `x` member. */ + x: string; + /** + * Key id the agent sends as `keyid`. Defaults to the RFC 8037 JWK SHA-256 + * thumbprint computed from `x`, which is what Web Bot Auth agents send. + */ + kid?: string; + /** Label reported as `agentDomain` when the request has no Signature-Agent header. */ + agent?: string; +} + +export interface WebBotAuthConfig { + /** + * App-wide default policy for capability HTTP endpoints. + * - `"observe"` (default): verify and surface `context.agent`, serve everyone. + * - `"require"`: unsigned/unverified requests to capability HTTP endpoints + * get a 401 envelope. Individual capabilities can override via `agentPolicy`. + */ + policy?: AgentPolicyMode; + /** Statically trusted keys (tests, air-gapped deploys, pinned agents). */ + keys?: WebBotAuthStaticKey[]; + /** + * Origins (e.g. `"https://signature-agent.example"`) whose + * `/.well-known/http-message-signatures-directory` may be fetched to + * resolve unknown key ids. Fetching is allowlist-only: an unlisted + * Signature-Agent fails verification instead of triggering a fetch + * (fail closed, no SSRF surface). + */ + directories?: string[]; + /** Allowed clock skew when checking `created`/`expires`, seconds. Default 60. */ + clockSkewSeconds?: number; + /** Maximum accepted signature lifetime (`expires - created`), seconds. Default 86400 (24h, per draft guidance). */ + maxLifetimeSeconds?: number; + /** In-memory TTL for fetched key directories, seconds. Default 300. */ + directoryCacheTtlSeconds?: number; +} + +export interface CapabilityConfirmationConfig { + /** Confirmation token TTL, seconds. Default 120. */ + ttlSeconds?: number; + /** + * Best-effort single-use enforcement via an in-memory, per-instance cache. + * Stateless HMAC tokens cannot prevent replay across instances or + * restarts — see docs/AGENT_TRUST.md for the honest limitations. + */ + singleUse?: boolean; +} + +export interface PrachtAgentsConfig { + /** Verify RFC 9421 / Web Bot Auth agent signatures and surface `context.agent`. */ + webBotAuth?: WebBotAuthConfig; + /** Prepare/commit confirmation flow options for destructive capabilities. */ + confirmation?: CapabilityConfirmationConfig; +} + +/** Structured audit event emitted for every capability dispatch. */ +export interface CapabilityAuditEvent { + capability: string; + effect: CapabilityEffect; + /** + * How the capability was invoked. `"webmcp"` reflects the transport marker + * the generated WebMCP shim sends with its dispatches — informational, not + * a trust signal (any HTTP client can send the header). + */ + transport: "http" | "server" | "webmcp"; + /** `"ok"` or the envelope error code (e.g. `"invalid_input"`, `"confirmation_required"`). */ + outcome: string; + /** HTTP status the envelope maps to (also set for server-side invocation). */ + status: number; + durationMs: number; + /** Verified agent identity, `null` when unsigned/unverified or Web Bot Auth is off. */ + agent: PrachtAgentIdentity | null; +} + +export type CapabilityAuditHook = (event: CapabilityAuditEvent) => void; + export interface PrachtAppConfig { shells?: Record; middleware?: Record; + /** + * Named capabilities defined with `defineCapability()` from + * `@pracht/capabilities`, registered like shells and middleware: + * `{ "notes.search": () => import("./capabilities/notes-search.ts") }`. + * Capability modules are server-only and private by default — a capability + * without an `expose` config is only callable via `invokeCapability()`. + */ + capabilities?: Record; + /** + * Agent trust configuration: Web Bot Auth verification policy/keys and the + * destructive-capability confirmation flow. Serializable data only. + */ + agents?: PrachtAgentsConfig; api?: ApiConfig; routes: RouteTreeNode[]; /** @@ -573,6 +704,8 @@ export interface PrachtAppConfig { export interface PrachtApp { shells: Record; middleware: Record; + capabilities: Record; + agents?: PrachtAgentsConfig; api: ApiConfig; routes: RouteTreeNode[]; notFound?: NotFoundDefinition; @@ -754,8 +887,61 @@ export interface ModuleRegistry { middlewareModules?: Record>; apiModules?: Record>; dataModules?: Record>; + capabilityModules?: Record>; +} + +// --------------------------------------------------------------------------- +// Capabilities +// +// The contract types live in `@pracht/capabilities` — the protocol-owning +// leaf package — and are re-exported here so framework consumers keep one +// import surface. `PrachtCapability` is the erased-generics view of +// `defineCapability()`'s return value that the runtime executes. +// --------------------------------------------------------------------------- + +export type { + CapabilityAgentPolicy, + CapabilityContext, + CapabilityEffect, + CapabilityEnvelope, + CapabilityErrorCode, + CapabilityErrorPayload, + CapabilityExposure, + CapabilityHttpExposure, + CapabilityIssue, + CapabilityRunArgs, + CapabilityValidationResult, + PrachtAgentIdentity, +} from "@pracht/capabilities"; + +export type PrachtCapability = Capability; + +export interface CapabilityModule { + default: PrachtCapability; } +/** + * `pracht typegen` generates capability input/output types from the JSON + * Schemas in the app's capability graph and registers them on + * `Register["capabilities"]`, mirroring how route typegen registers + * `Register["routes"]`. Once registered, `invokeCapability()` (and the + * browser's `callCapability()`) infer input and output types from the + * capability name — no per-call generics needed. + */ +type RegisteredCapabilityMap = Register extends { capabilities: infer TCapabilities } + ? TCapabilities extends Record + ? TCapabilities + : {} + : {}; + +export type RegisteredCapabilityName = Extract; + +export type CapabilityInputFor = + RegisteredCapabilityMap[TName] extends { input: infer TInput } ? TInput : unknown; + +export type CapabilityOutputFor = + RegisteredCapabilityMap[TName] extends { output: infer TOutput } ? TOutput : unknown; + export class PrachtHttpError extends Error { readonly status: number; diff --git a/packages/framework/test/agent-auth.test.ts b/packages/framework/test/agent-auth.test.ts new file mode 100644 index 00000000..a2e95aef --- /dev/null +++ b/packages/framework/test/agent-auth.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + clearAgentDirectoryCache, + ed25519JwkThumbprint, + parseDirectoryJwks, + parseSignatureAgent, + parseSignatureHeader, + parseSignatureInput, + verifyAgentSignature, +} from "../src/runtime-agent-auth.ts"; + +// Fixed Ed25519 test keypair (also used by examples/basic and the e2e suite). +// The private part exists only in tests; the public `x` is safe to commit. +const TEST_PUBLIC_X = "s5n91rPm5ymJjl--scT4WWq7HE9kUdj-6sVe5r__xgc"; +const TEST_PRIVATE_D = "JZlLQqnxH-0O_1mfnuqDBB1U5XgqETE5eiRXxXRhZNM"; +const TEST_KEY_THUMBPRINT = "9zaO23t4-sitQq-zx7KAn4Q1Ds_W1PF07ozJfoP3H70"; + +const NOW = 1_800_000_000; + +interface SignOptions { + url?: string; + method?: string; + components?: string[]; + signatureAgent?: string | null; + created?: number; + expires?: number; + keyid?: string; + alg?: string | null; + tag?: string; + /** Corrupt the signature bytes after signing. */ + tamper?: boolean; + privateD?: string; + publicX?: string; +} + +/** + * Independent RFC 9421 signer used to produce test requests — mirrors what a + * Web Bot Auth agent sends (draft-meunier-web-bot-auth-architecture-02). + */ +async function signedRequest(options: SignOptions = {}): Promise { + const url = options.url ?? "https://app.example/api/capabilities/agent/whoami"; + const method = options.method ?? "POST"; + const signatureAgent = + options.signatureAgent === undefined ? '"https://agent.example"' : options.signatureAgent; + const components = + options.components ?? (signatureAgent ? ["@authority", "signature-agent"] : ["@authority"]); + const created = options.created ?? NOW - 10; + const expires = options.expires ?? NOW + 300; + const keyid = options.keyid ?? TEST_KEY_THUMBPRINT; + + const headers = new Headers({ "content-type": "application/json" }); + if (signatureAgent) headers.set("signature-agent", signatureAgent); + + const componentList = components.map((component) => `"${component}"`).join(" "); + const algPart = options.alg === null ? "" : `;alg="${options.alg ?? "ed25519"}"`; + const params = + `(${componentList});created=${created};expires=${expires}` + + `;keyid="${keyid}"${algPart};tag="${options.tag ?? "web-bot-auth"}"`; + + const parsedUrl = new URL(url); + const lines = components.map((component) => { + if (component === "@authority") return `"@authority": ${parsedUrl.host}`; + if (component === "@method") return `"@method": ${method}`; + if (component === "@path") return `"@path": ${parsedUrl.pathname}`; + return `"${component}": ${headers.get(component)}`; + }); + lines.push(`"@signature-params": ${params}`); + const base = lines.join("\n"); + + const privateKey = await crypto.subtle.importKey( + "jwk", + { + kty: "OKP", + crv: "Ed25519", + d: options.privateD ?? TEST_PRIVATE_D, + x: options.publicX ?? TEST_PUBLIC_X, + }, + { name: "Ed25519" }, + false, + ["sign"], + ); + const signature = new Uint8Array( + await crypto.subtle.sign({ name: "Ed25519" }, privateKey, new TextEncoder().encode(base)), + ); + if (options.tamper) signature[0] ^= 0xff; + + let binary = ""; + for (const byte of signature) binary += String.fromCharCode(byte); + headers.set("signature-input", `sig1=${params}`); + headers.set("signature", `sig1=:${btoa(binary)}:`); + + return new Request(url, { method, headers, body: "{}" }); +} + +const staticOptions = { + keys: [{ x: TEST_PUBLIC_X, agent: "pinned-agent.example" }], + now: () => NOW, +}; + +afterEach(() => { + clearAgentDirectoryCache(); +}); + +describe("structured field parsing", () => { + it("parses Signature-Input dictionaries with components and params", () => { + const members = parseSignatureInput( + 'sig1=("@authority" "signature-agent");created=1;expires=2;keyid="abc";tag="web-bot-auth"', + ); + expect(members).toHaveLength(1); + expect(members![0].label).toBe("sig1"); + expect(members![0].components).toEqual(["@authority", "signature-agent"]); + expect(members![0].params).toEqual({ + created: 1, + expires: 2, + keyid: "abc", + tag: "web-bot-auth", + }); + }); + + it("returns null for malformed Signature-Input headers", () => { + expect(parseSignatureInput("sig1=nope")).toBeNull(); + expect(parseSignatureInput('sig1=("@authority";created=x')).toBeNull(); + expect(parseSignatureInput("sig1=(@authority);created=1")).toBeNull(); + }); + + it("parses Signature byte-sequence dictionaries and rejects malformed ones", () => { + const parsed = parseSignatureHeader("sig1=:aGVsbG8=:"); + expect(parsed).not.toBeNull(); + expect(new TextDecoder().decode(parsed!.sig1)).toBe("hello"); + expect(parseSignatureHeader("sig1=aGVsbG8=")).toBeNull(); + expect(parseSignatureHeader("sig1=:!!!:")).toBeNull(); + }); + + it("parses Signature-Agent as an https-only sf-string", () => { + expect(parseSignatureAgent('"https://agent.example"')?.host).toBe("agent.example"); + expect(parseSignatureAgent("https://agent.example")?.host).toBe("agent.example"); + expect(parseSignatureAgent('"http://agent.example"')).toBeNull(); + expect(parseSignatureAgent('"https://user:pw@agent.example"')).toBeNull(); + expect(parseSignatureAgent('"not a url"')).toBeNull(); + }); +}); + +describe("verifyAgentSignature", () => { + it("verifies a signed request against a static key (round-trip)", async () => { + const request = await signedRequest(); + const identity = await verifyAgentSignature(request, staticOptions); + expect(identity).toEqual({ + verified: true, + agentDomain: "pinned-agent.example", + keyId: TEST_KEY_THUMBPRINT, + }); + }); + + it("does not let Signature-Agent override a static key's pinned label", async () => { + const request = await signedRequest({ signatureAgent: '"https://trusted.example"' }); + const identity = await verifyAgentSignature(request, staticOptions); + expect(identity?.agentDomain).toBe("pinned-agent.example"); + }); + + it("uses the static key's agent label when no Signature-Agent header is sent", async () => { + const request = await signedRequest({ signatureAgent: null }); + const identity = await verifyAgentSignature(request, staticOptions); + expect(identity?.agentDomain).toBe("pinned-agent.example"); + }); + + it("returns null for unsigned requests", async () => { + const request = new Request("https://app.example/", { method: "POST" }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed on a tampered signature", async () => { + const request = await signedRequest({ tamper: true }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed when signed by a different key", async () => { + // A fresh keypair signs, but the keyid still points at the trusted key. + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const jwk = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as { + d: string; + x: string; + }; + const request = await signedRequest({ privateD: jwk.d, publicX: jwk.x }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed on an expired signature", async () => { + const request = await signedRequest({ created: NOW - 700, expires: NOW - 400 }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("allows small clock skew on expiry", async () => { + const request = await signedRequest({ created: NOW - 400, expires: NOW - 30 }); + expect(await verifyAgentSignature(request, staticOptions)).not.toBeNull(); + }); + + it("fails closed on a not-yet-valid signature", async () => { + const request = await signedRequest({ created: NOW + 600, expires: NOW + 900 }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed when @authority is not covered", async () => { + const request = await signedRequest({ components: ["@method", "signature-agent"] }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed when signature-agent is sent but not covered", async () => { + const request = await signedRequest({ components: ["@authority"] }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("ignores signatures without the web-bot-auth tag", async () => { + const request = await signedRequest({ tag: "other-tag" }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); + + it("fails closed when the authority differs (signature bound to another host)", async () => { + const request = await signedRequest(); + const replayed = new Request("https://other.example/api/capabilities/agent/whoami", { + method: "POST", + headers: request.headers, + body: "{}", + }); + expect(await verifyAgentSignature(replayed, staticOptions)).toBeNull(); + }); + + it("rejects non-ed25519 alg parameters", async () => { + const request = await signedRequest({ alg: "rsa-pss-sha512" }); + expect(await verifyAgentSignature(request, staticOptions)).toBeNull(); + }); +}); + +describe("key directory resolution", () => { + function directoryFetch(jwks: unknown): { fetchImpl: typeof fetch; calls: string[] } { + const calls: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return new Response(JSON.stringify(jwks), { + status: 200, + headers: { "content-type": "application/http-message-signatures-directory+json" }, + }); + }) as typeof fetch; + return { fetchImpl, calls }; + } + + const directoryJwks = { keys: [{ kty: "OKP", crv: "Ed25519", x: TEST_PUBLIC_X }] }; + + it("resolves keys from an allowlisted directory and caches by TTL", async () => { + const { fetchImpl, calls } = directoryFetch(directoryJwks); + const options = { + directories: ["https://agent.example"], + now: () => NOW, + fetchImpl, + }; + + const first = await verifyAgentSignature(await signedRequest(), options); + expect(first?.agentDomain).toBe("agent.example"); + expect(calls).toEqual(["https://agent.example/.well-known/http-message-signatures-directory"]); + + // Second verification within the TTL reuses the cached directory. + const second = await verifyAgentSignature(await signedRequest(), options); + expect(second?.verified).toBe(true); + expect(calls).toHaveLength(1); + + // After the cache is cleared the directory is fetched again. + clearAgentDirectoryCache(); + await verifyAgentSignature(await signedRequest(), options); + expect(calls).toHaveLength(2); + }); + + it("never fetches directories that are not allowlisted", async () => { + const { fetchImpl, calls } = directoryFetch(directoryJwks); + const identity = await verifyAgentSignature(await signedRequest(), { + directories: ["https://other-agent.example"], + now: () => NOW, + fetchImpl, + }); + expect(identity).toBeNull(); + expect(calls).toEqual([]); + }); + + it("fails closed when the directory fetch errors", async () => { + const fetchImpl = (async () => { + throw new Error("boom"); + }) as typeof fetch; + const identity = await verifyAgentSignature(await signedRequest(), { + directories: ["https://agent.example"], + now: () => NOW, + fetchImpl, + }); + expect(identity).toBeNull(); + }); + + it("cancels directory bodies as soon as they exceed the size cap", async () => { + let pullCount = 0; + let cancelled = false; + const fetchImpl = (async () => + new Response( + new ReadableStream({ + pull(controller) { + if (pullCount++ === 0) controller.enqueue(new Uint8Array(65_536)); + else controller.enqueue(new Uint8Array([0])); + }, + cancel() { + cancelled = true; + }, + }), + )) as typeof fetch; + + const identity = await verifyAgentSignature(await signedRequest(), { + directories: ["https://agent.example"], + now: () => NOW, + fetchImpl, + }); + + expect(identity).toBeNull(); + expect(cancelled).toBe(true); + }); + + it("parses JWKS entries, dropping non-Ed25519 and kid-mismatched keys", async () => { + const keys = await parseDirectoryJwks({ + keys: [ + { kty: "OKP", crv: "Ed25519", x: TEST_PUBLIC_X }, + { kty: "OKP", crv: "Ed25519", x: TEST_PUBLIC_X, kid: "wrong-kid" }, + { kty: "RSA", n: "...", e: "AQAB" }, + { kty: "OKP", crv: "X25519", x: "abc" }, + "garbage", + ], + }); + expect(keys).toEqual([{ keyId: TEST_KEY_THUMBPRINT, x: TEST_PUBLIC_X, agent: null }]); + expect(await parseDirectoryJwks(null)).toEqual([]); + expect(await parseDirectoryJwks({ keys: "nope" })).toEqual([]); + }); + + it("computes the RFC 8037 JWK thumbprint used as keyid", async () => { + expect(await ed25519JwkThumbprint(TEST_PUBLIC_X)).toBe(TEST_KEY_THUMBPRINT); + }); +}); diff --git a/packages/framework/test/agent-trust.test.ts b/packages/framework/test/agent-trust.test.ts new file mode 100644 index 00000000..4acfceaf --- /dev/null +++ b/packages/framework/test/agent-trust.test.ts @@ -0,0 +1,429 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { defineCapability } from "../../capabilities/src/index.ts"; +import { + defineApp, + handlePrachtRequest, + resolveApp, + route, + setCapabilityAuditHook, +} from "../src/index.ts"; +import { + canonicalJson, + clearConsumedConfirmationTokens, + consumeConfirmationToken, + createConfirmationToken, + setCapabilityConfirmationSecret, + verifyConfirmationToken, +} from "../src/runtime-confirmation.ts"; +import type { CapabilityAuditEvent, ModuleRegistry, PrachtAgentsConfig } from "../src/types.ts"; + +type CapabilityDefinition = Parameters[0]; + +const SECRET = "unit-test-confirmation-secret"; + +function createPurgeCapability(overrides: Record = {}) { + return defineCapability({ + title: "Purge notes", + description: "Delete notes.", + input: { + type: "object", + properties: { titlePrefix: { type: "string", minLength: 1 } }, + required: ["titlePrefix"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { purged: { type: "integer" } }, + required: ["purged"], + }, + effect: "destructive", + expose: { http: true }, + async run() { + return { purged: 1 }; + }, + ...overrides, + } as CapabilityDefinition); +} + +function createApp( + capabilityModule: unknown, + agents: PrachtAgentsConfig | undefined = { confirmation: { ttlSeconds: 120 } }, +) { + const app = defineApp({ + capabilities: { "notes.purge": "./capabilities/notes-purge.ts" }, + agents, + routes: [route("/", "./routes/home.tsx")], + }); + + const registry: ModuleRegistry = { + routeModules: { "./routes/home.tsx": async () => ({ Component: () => null }) }, + capabilityModules: { + "./capabilities/notes-purge.ts": (async () => ({ + default: capabilityModule, + })) as NonNullable[string], + }, + }; + + return { app, registry }; +} + +function postPurge(input: unknown, headers: Record = {}): Request { + return new Request("http://localhost/api/capabilities/notes/purge", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(input), + }); +} + +beforeEach(() => { + setCapabilityConfirmationSecret(SECRET); +}); + +afterEach(() => { + setCapabilityConfirmationSecret(null); + clearConsumedConfirmationTokens(); + setCapabilityAuditHook(null); +}); + +// --------------------------------------------------------------------------- +// Confirmation token primitives +// --------------------------------------------------------------------------- + +describe("confirmation tokens", () => { + const binding = { + secret: SECRET, + principal: "anonymous", + capability: "notes.purge", + canonicalInput: canonicalJson({ titlePrefix: "x" }), + }; + + it("round-trips: created tokens verify against the same binding", async () => { + const { token, expiresAt } = await createConfirmationToken({ ...binding, ttlSeconds: 120 }); + expect(expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + const result = await verifyConfirmationToken(token, binding); + expect(result.ok).toBe(true); + }); + + it("rejects expired tokens", async () => { + const past = Math.floor(Date.now() / 1000) - 600; + const { token } = await createConfirmationToken({ ...binding, ttlSeconds: 120, now: past }); + const result = await verifyConfirmationToken(token, binding); + expect(result).toEqual({ ok: false, reason: "expired" }); + }); + + it("rejects tampered tokens", async () => { + const { token } = await createConfirmationToken({ ...binding, ttlSeconds: 120 }); + const [version, , signature] = token.split("."); + // Forge different claims but keep the original signature. + const forgedPayload = btoa( + JSON.stringify({ p: "anonymous", c: "other.capability", i: "x", exp: 9999999999 }), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const forged = `${version}.${forgedPayload}.${signature}`; + const result = await verifyConfirmationToken(forged, binding); + expect(result).toEqual({ ok: false, reason: "bad_signature" }); + expect(await verifyConfirmationToken("v1.garbage", binding)).toEqual({ + ok: false, + reason: "malformed", + }); + }); + + it("rejects principal, capability, and input mismatches", async () => { + const { token } = await createConfirmationToken({ ...binding, ttlSeconds: 120 }); + expect(await verifyConfirmationToken(token, { ...binding, principal: "agent:abc" })).toEqual({ + ok: false, + reason: "principal_mismatch", + }); + expect( + await verifyConfirmationToken(token, { ...binding, capability: "notes.create" }), + ).toEqual({ ok: false, reason: "capability_mismatch" }); + expect( + await verifyConfirmationToken(token, { + ...binding, + canonicalInput: canonicalJson({ titlePrefix: "y" }), + }), + ).toEqual({ ok: false, reason: "input_mismatch" }); + }); + + it("canonicalizes JSON independently of key order", () => { + expect(canonicalJson({ b: 1, a: { d: [1, 2], c: null } })).toBe( + canonicalJson({ a: { c: null, d: [1, 2] }, b: 1 }), + ); + expect(canonicalJson({ a: 1, b: undefined })).toBe('{"a":1}'); + }); + + it("enforces single-use per instance", () => { + const expiresAt = Math.floor(Date.now() / 1000) + 60; + expect(consumeConfirmationToken("sig-a", expiresAt)).toBe(true); + expect(consumeConfirmationToken("sig-a", expiresAt)).toBe(false); + expect(consumeConfirmationToken("sig-b", expiresAt)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP prepare/commit flow +// --------------------------------------------------------------------------- + +describe("destructive capability HTTP flow", () => { + it("answers confirmation_required with a token instead of running", async () => { + const { app, registry } = createApp(createPurgeCapability()); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.error.code).toBe("confirmation_required"); + expect(typeof body.error.confirmationToken).toBe("string"); + expect(typeof body.error.expiresAt).toBe("number"); + }); + + it("runs on commit with the token and byte-identical canonical input", async () => { + const { app, registry } = createApp(createPurgeCapability()); + const prepare = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + const token = (await prepare.json()).error.confirmationToken as string; + + const commit = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }, { "x-pracht-confirm": token }), + }); + expect(commit.status).toBe(200); + expect(await commit.json()).toEqual({ ok: true, data: { purged: 1 } }); + }); + + it("rejects commits whose input differs from the prepared input", async () => { + const { app, registry } = createApp(createPurgeCapability()); + const prepare = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + const token = (await prepare.json()).error.confirmationToken as string; + + const commit = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "everything" }, { "x-pracht-confirm": token }), + }); + expect(commit.status).toBe(403); + const body = await commit.json(); + expect(body.error.code).toBe("confirmation_invalid"); + expect(body.error.message).toContain("input_mismatch"); + }); + + it("rejects tampered tokens with 403", async () => { + const { app, registry } = createApp(createPurgeCapability()); + const commit = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }, { "x-pracht-confirm": "v1.fake.fake" }), + }); + expect(commit.status).toBe(403); + expect((await commit.json()).error.code).toBe("confirmation_invalid"); + }); + + it("fails closed when no confirmation secret is configured", async () => { + setCapabilityConfirmationSecret(null); + const { app, registry } = createApp(createPurgeCapability()); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.code).toBe("confirmation_unavailable"); + expect(body.error.confirmationToken).toBeUndefined(); + }); + + it("still validates input first (400 before any token logic)", async () => { + const { app, registry } = createApp(createPurgeCapability()); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({}), + }); + expect(response.status).toBe(400); + expect((await response.json()).error.code).toBe("invalid_input"); + }); + + it("runs capability middleware during a prepare, not just the commit", async () => { + let middlewareCalls = 0; + const app = defineApp({ + capabilities: { "notes.purge": "./capabilities/notes-purge.ts" }, + middleware: { count: "./middleware/count.ts" }, + agents: { confirmation: { ttlSeconds: 120 } }, + routes: [route("/", "./routes/home.tsx")], + }); + const registry: ModuleRegistry = { + routeModules: { "./routes/home.tsx": async () => ({ Component: () => null }) }, + capabilityModules: { + "./capabilities/notes-purge.ts": (async () => ({ + default: createPurgeCapability({ middleware: ["count"] }), + })) as NonNullable[string], + }, + middlewareModules: { + "./middleware/count.ts": (async () => ({ + middleware: async (_args: unknown, next: () => Promise) => { + middlewareCalls += 1; + return next(); + }, + })) as NonNullable[string], + }, + }; + + const prepare = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + expect(prepare.status).toBe(409); + // The confirmation gate now runs inside the middleware chain, so + // rate-limiting middleware sees the prepare attempt too. + expect(middlewareCalls).toBe(1); + }); + + it("optionally consumes tokens once per instance (singleUse)", async () => { + const { app, registry } = createApp(createPurgeCapability(), { + confirmation: { ttlSeconds: 120, singleUse: true }, + }); + const prepare = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + const token = (await prepare.json()).error.confirmationToken as string; + + const first = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }, { "x-pracht-confirm": token }), + }); + expect(first.status).toBe(200); + + const replay = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }, { "x-pracht-confirm": token }), + }); + expect(replay.status).toBe(403); + expect((await replay.json()).error.message).toContain("already_used"); + }); +}); + +// --------------------------------------------------------------------------- +// Web Bot Auth policy + context.agent + audit trail +// --------------------------------------------------------------------------- + +describe("agent policy and audit", () => { + it('"require" policy rejects unsigned requests with the 401 envelope', async () => { + const { app, registry } = createApp( + createPurgeCapability({ effect: "read", agentPolicy: "require" }), + { webBotAuth: { policy: "observe" } }, + ); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + expect(response.status).toBe(401); + expect((await response.json()).error.code).toBe("agent_required"); + }); + + it('"observe" mode serves unsigned requests and sets context.agent to null', async () => { + const capability = createPurgeCapability({ + effect: "read", + output: { + type: "object", + properties: { agentChecked: { type: "boolean" } }, + required: ["agentChecked"], + }, + async run({ context }: { context: { agent?: unknown } }) { + return { agentChecked: "agent" in context && context.agent === null }; + }, + }); + const { app, registry } = createApp(capability, { webBotAuth: { policy: "observe" } }); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + }); + expect(await response.json()).toEqual({ ok: true, data: { agentChecked: true } }); + }); + + it("emits audit events with capability, effect, outcome, and duration", async () => { + const events: CapabilityAuditEvent[] = []; + setCapabilityAuditHook((event) => events.push(event)); + + const { app, registry } = createApp(createPurgeCapability()); + await handlePrachtRequest({ app, registry, request: postPurge({ titlePrefix: "x" }) }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + capability: "notes.purge", + effect: "destructive", + transport: "http", + outcome: "confirmation_required", + status: 409, + agent: null, + }); + expect(events[0].durationMs).toBeGreaterThanOrEqual(0); + }); + + it("also invokes the onCapabilityAudit runtime option and survives hook errors", async () => { + setCapabilityAuditHook(() => { + throw new Error("hook exploded"); + }); + const events: CapabilityAuditEvent[] = []; + + const { app, registry } = createApp(createPurgeCapability({ effect: "read" })); + const response = await handlePrachtRequest({ + app, + registry, + request: postPurge({ titlePrefix: "x" }), + onCapabilityAudit: (event) => events.push(event), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(1); + expect(events[0].outcome).toBe("ok"); + }); +}); + +describe("agents config validation", () => { + function buildApp(agents: PrachtAgentsConfig) { + return defineApp({ + agents, + routes: [route("/", "./routes/home.tsx")], + }); + } + + it("rejects an unknown webBotAuth policy (fails closed, not open)", () => { + const app = buildApp({ webBotAuth: { policy: "requre" as never } }); + expect(() => resolveApp(app)).toThrow(/policy/); + }); + + it("accepts the valid policies", () => { + expect(() => resolveApp(buildApp({ webBotAuth: { policy: "require" } }))).not.toThrow(); + expect(() => resolveApp(buildApp({ webBotAuth: { policy: "observe" } }))).not.toThrow(); + }); + + it("rejects non-positive numeric trust settings", () => { + expect(() => resolveApp(buildApp({ confirmation: { ttlSeconds: 0 } }))).toThrow( + /positive number/, + ); + expect(() => resolveApp(buildApp({ webBotAuth: { clockSkewSeconds: -1 } }))).toThrow( + /positive number/, + ); + }); +}); diff --git a/packages/framework/test/capabilities.test.ts b/packages/framework/test/capabilities.test.ts new file mode 100644 index 00000000..1f501e68 --- /dev/null +++ b/packages/framework/test/capabilities.test.ts @@ -0,0 +1,848 @@ +import { describe, expect, it } from "vitest"; + +import { + CAPABILITY_EFFECT_HEADER, + CAPABILITY_FORM_REDIRECT_HEADER, + CAPABILITY_FORM_REQUEST_HEADER, + defineCapability, +} from "../../capabilities/src/index.ts"; +import { defineApp, handlePrachtRequest, invokeCapability, route } from "../src/index.ts"; +import { + capabilityHttpPath, + matchCapabilityRoute, + resolveAppCapabilities, +} from "../src/runtime-capabilities.ts"; +import type { LoaderArgs, ModuleRegistry } from "../src/types.ts"; + +type CapabilityDefinition = Parameters[0]; + +function createSearchCapability(overrides: Record = {}) { + return defineCapability({ + title: "Search notes", + description: "Find notes.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array", items: { type: "string" } } }, + required: ["notes"], + }, + effect: "read", + expose: { http: true }, + async run({ input }) { + const typed = input as { query: string; limit: number }; + return { notes: [`${typed.query}:${typed.limit}`] }; + }, + ...overrides, + } as CapabilityDefinition); +} + +function createApp(capabilityModule: unknown, options: Record = {}) { + const app = defineApp({ + middleware: { + deny: "./middleware/deny.ts", + passthrough: "./middleware/passthrough.ts", + redirect: "./middleware/redirect.ts", + relativeRedirect: "./middleware/relative-redirect.ts", + }, + capabilities: { + "notes.search": "./capabilities/notes-search.ts", + }, + routes: [route("/", "./routes/home.tsx")], + ...options, + }); + + const registry: ModuleRegistry = { + routeModules: { + "./routes/home.tsx": async () => ({ Component: () => null }), + }, + middlewareModules: { + "./middleware/deny.ts": async () => ({ + middleware: async () => new Response("denied", { status: 401 }), + }), + "./middleware/passthrough.ts": async () => ({ + middleware: async ( + args: { context: Record }, + next: () => Promise, + ) => { + args.context.fromMiddleware = true; + return next(); + }, + }), + "./middleware/redirect.ts": async () => ({ + middleware: async () => + new Response(null, { + status: 303, + headers: { location: "https://auth.example/login" }, + }), + }), + "./middleware/relative-redirect.ts": async () => ({ + middleware: async () => + new Response(null, { + status: 303, + headers: { location: "login" }, + }), + }), + }, + capabilityModules: { + "./capabilities/notes-search.ts": (async () => ({ + default: capabilityModule, + })) as NonNullable[string], + }, + }; + + return { app, registry }; +} + +function postCapability(path: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("capabilityHttpPath", () => { + it("maps dots to slashes under the capabilities prefix", () => { + expect(capabilityHttpPath("notes.search")).toBe("/api/capabilities/notes/search"); + expect(capabilityHttpPath("archive")).toBe("/api/capabilities/archive"); + expect(capabilityHttpPath("projects.tasks.create")).toBe( + "/api/capabilities/projects/tasks/create", + ); + }); +}); + +describe("resolveAppCapabilities", () => { + it("resolves registered capabilities with default HTTP paths", async () => { + const { app, registry } = createApp(createSearchCapability()); + const resolved = await resolveAppCapabilities(app, registry); + + expect(resolved).toHaveLength(1); + expect(resolved[0].name).toBe("notes.search"); + expect(resolved[0].httpPath).toBe("/api/capabilities/notes/search"); + expect(matchCapabilityRoute(resolved, "/api/capabilities/notes/search")?.name).toBe( + "notes.search", + ); + expect(matchCapabilityRoute(resolved, "/api/capabilities/notes/other")).toBeUndefined(); + }); + + it("honors custom HTTP paths", async () => { + const { app, registry } = createApp( + createSearchCapability({ expose: { http: { path: "/api/find-notes" } } }), + ); + const resolved = await resolveAppCapabilities(app, registry); + expect(resolved[0].httpPath).toBe("/api/find-notes"); + }); + + it("rejects unsafe paths on hand-rolled capability objects", async () => { + const capability = createSearchCapability(); + capability.expose!.http!.path = "//evil.test/collect"; + const { app, registry } = createApp(capability); + + await expect(resolveAppCapabilities(app, registry)).rejects.toThrow( + /exact same-origin pathname/, + ); + }); + + it("normalizes trailing slashes on custom HTTP paths", async () => { + const { app, registry } = createApp( + createSearchCapability({ expose: { http: { path: "/api/find-notes/" } } }), + ); + const resolved = await resolveAppCapabilities(app, registry); + + expect(resolved[0].httpPath).toBe("/api/find-notes"); + expect(matchCapabilityRoute(resolved, "/api/find-notes/")?.name).toBe("notes.search"); + }); + + it("rejects custom HTTP paths that only differ by a trailing slash", async () => { + const first = createSearchCapability({ expose: { http: { path: "/api/find-notes" } } }); + const second = createSearchCapability({ expose: { http: { path: "/api/find-notes/" } } }); + const app = defineApp({ + capabilities: { + "notes.first": "./capabilities/first.ts", + "notes.second": "./capabilities/second.ts", + }, + routes: [route("/", "./routes/home.tsx")], + }); + const registry: ModuleRegistry = { + capabilityModules: { + "./capabilities/first.ts": async () => ({ default: first }), + "./capabilities/second.ts": async () => ({ default: second }), + }, + }; + + await expect(resolveAppCapabilities(app, registry)).rejects.toThrow(/both expose HTTP path/); + }); + + it("keeps unexposed capabilities private (no HTTP path)", async () => { + const { app, registry } = createApp(createSearchCapability({ expose: undefined })); + const resolved = await resolveAppCapabilities(app, registry); + expect(resolved[0].httpPath).toBeNull(); + }); + + it("rejects unknown middleware names with a helpful error", async () => { + const { app, registry } = createApp(createSearchCapability({ middleware: ["authz"] })); + await expect(resolveAppCapabilities(app, registry)).rejects.toThrow(/Unknown middleware/); + }); + + it("rejects modules that do not export a capability", async () => { + const { app, registry } = createApp({ not: "a capability" }); + await expect(resolveAppCapabilities(app, registry)).rejects.toThrow( + /must default-export the result of defineCapability/, + ); + }); + + it("rejects hand-rolled destructive capability objects exposed to agent projections", async () => { + const capability = { + ...createSearchCapability({ expose: { http: true, webmcp: true } }), + effect: "destructive" as const, + }; + const { app, registry } = createApp(capability); + await expect(resolveAppCapabilities(app, registry)).rejects.toThrow( + /destructive capabilities cannot be exposed to agent projections/, + ); + }); + + it("accepts destructive capabilities exposed over HTTP only", async () => { + const capability = { ...createSearchCapability(), effect: "destructive" as const }; + const { app, registry } = createApp(capability); + const resolved = await resolveAppCapabilities(app, registry); + expect(resolved[0].httpPath).toBe("/api/capabilities/notes/search"); + }); + + it("refreshes resolved modules when the generated capability registry changes", async () => { + const { app, registry } = createApp(createSearchCapability({ title: "Before edit" })); + const first = await resolveAppCapabilities(app, registry); + expect(first[0].capability.title).toBe("Before edit"); + + const nextRegistry: ModuleRegistry = { + ...registry, + capabilityModules: { + "./capabilities/notes-search.ts": async () => ({ + default: createSearchCapability({ title: "After edit" }), + }), + }, + }; + + const second = await resolveAppCapabilities(app, nextRegistry); + expect(second[0].capability.title).toBe("After edit"); + }); +}); + +describe("capability HTTP projection", () => { + it("returns enhanced form redirects without fetching the external target", async () => { + const { app, registry } = createApp(createSearchCapability({ middleware: ["redirect"] })); + const request = postCapability("/api/capabilities/notes/search", { query: "hello" }); + request.headers.set(CAPABILITY_FORM_REQUEST_HEADER, "1"); + + const response = await handlePrachtRequest({ app, registry, request }); + + expect(response.status).toBe(204); + expect(response.headers.get("location")).toBeNull(); + expect(response.headers.get(CAPABILITY_FORM_REDIRECT_HEADER)).toBe( + "https://auth.example/login", + ); + expect(response.headers.get(CAPABILITY_EFFECT_HEADER)).toBe("read"); + }); + + it("preserves redirects for non-enhanced capability callers", async () => { + const { app, registry } = createApp(createSearchCapability({ middleware: ["redirect"] })); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hello" }), + }); + + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("https://auth.example/login"); + expect(response.headers.get(CAPABILITY_FORM_REDIRECT_HEADER)).toBeNull(); + }); + + it("resolves relative enhanced-form redirects against the capability endpoint", async () => { + const { app, registry } = createApp( + createSearchCapability({ middleware: ["relativeRedirect"] }), + ); + const request = postCapability("/api/capabilities/notes/search", { query: "hello" }); + request.headers.set(CAPABILITY_FORM_REQUEST_HEADER, "1"); + + const response = await handlePrachtRequest({ app, registry, request }); + + expect(response.status).toBe(204); + expect(response.headers.get(CAPABILITY_FORM_REDIRECT_HEADER)).toBe( + "http://localhost/api/capabilities/notes/login", + ); + }); + + it("serves an exposed capability at the default path", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hello" }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get(CAPABILITY_EFFECT_HEADER)).toBe("read"); + // Input defaults were applied before run(). + expect(await response.json()).toEqual({ ok: true, data: { notes: ["hello:10"] } }); + }); + + it("returns 400 with path-scoped issues for invalid input", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "", limit: 99 }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("invalid_input"); + expect(body.error.issues).toEqual([ + { path: "/query", message: "must be at least 1 character(s) long" }, + { path: "/limit", message: "must be <= 50" }, + ]); + }); + + it("returns 400 for malformed JSON bodies", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/api/capabilities/notes/search", { + method: "POST", + body: "{nope", + }), + }); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).toBe("invalid_json"); + }); + + it("passes explicit null input through a null schema", async () => { + const capability = defineCapability({ + title: "Accept null", + description: "Accept a null input.", + input: { type: "null" }, + output: { type: "null" }, + effect: "read", + expose: { http: true }, + run: ({ input }) => input, + }); + const { app, registry } = createApp(capability); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", null), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, data: null }); + }); + + it("returns 405 for non-POST methods on a capability path", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/api/capabilities/notes/search"), + }); + + expect(response.status).toBe(405); + expect((await response.json()).error.code).toBe("method_not_allowed"); + }); + + it("returns a typed 404 for unknown paths under the capability prefix", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/missing", {}), + }); + + expect(response.status).toBe(404); + expect((await response.json()).error.code).toBe("unknown_capability"); + }); + + it("does not serve private capabilities over HTTP", async () => { + const { app, registry } = createApp(createSearchCapability({ expose: undefined })); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hello" }), + }); + + expect(response.status).toBe(404); + expect((await response.json()).error.code).toBe("unknown_capability"); + }); + + it("lets middleware deny the request as an envelope with the original status", async () => { + const { app, registry } = createApp(createSearchCapability({ middleware: ["deny"] })); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hello" }), + }); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.ok).toBe(false); + expect(body.error.code).toBe("unauthorized"); + }); + + it("runs middleware before the handler with a shared mutable context", async () => { + const capability = createSearchCapability({ + middleware: ["passthrough"], + async run({ + input, + context, + }: { + input: { query: string }; + context: Record; + }) { + return { notes: [String(context.fromMiddleware), input.query] }; + }, + }); + const { app, registry } = createApp(capability); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hi" }), + }); + + expect(await response.json()).toEqual({ ok: true, data: { notes: ["true", "hi"] } }); + }); + + it("treats invalid output as a redacted server error, never returning it raw", async () => { + const capability = createSearchCapability({ + async run() { + return { secret: "internal-value" }; + }, + }); + const { app, registry } = createApp(capability); + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hi" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error.code).toBe("invalid_output"); + // Redacted by default: no issues, no schema details, no raw output. + expect(body.error.message).toBe("Capability failed."); + expect(body.error.issues).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain("internal-value"); + }); + + it("exposes output validation details only with debugErrors", async () => { + const capability = createSearchCapability({ + async run() { + return {}; + }, + }); + const { app, registry } = createApp(capability); + + const response = await handlePrachtRequest({ + app, + registry, + debugErrors: true, + request: postCapability("/api/capabilities/notes/search", { query: "hi" }), + }); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.error.message).toContain("does not match its output schema"); + expect(body.error.issues).toEqual([{ path: "/notes", message: "is required" }]); + }); + + it("redacts thrown run() errors unless debugErrors is set", async () => { + const capability = createSearchCapability({ + async run() { + throw new Error("database exploded"); + }, + }); + const { app, registry } = createApp(capability); + + const redacted = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/capabilities/notes/search", { query: "hi" }), + }); + expect(redacted.status).toBe(500); + expect(JSON.stringify(await redacted.json())).not.toContain("database exploded"); + + const debug = await handlePrachtRequest({ + app, + registry, + debugErrors: true, + request: postCapability("/api/capabilities/notes/search", { query: "hi" }), + }); + expect((await debug.json()).error.message).toContain("database exploded"); + }); + + it("blocks cross-origin capability POSTs by default", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/api/capabilities/notes/search", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://evil.example", + }, + body: JSON.stringify({ query: "hello" }), + }), + }); + + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe("cross_origin_blocked"); + }); + + it("gives explicit API route files precedence over capability paths", async () => { + const { app, registry } = createApp( + createSearchCapability({ expose: { http: { path: "/api/health" } } }), + ); + registry.apiModules = { + "/src/api/health.ts": async () => ({ + POST: async () => Response.json({ from: "api-route" }), + }), + }; + + const { resolveApiRoutes } = await import("../src/index.ts"); + const response = await handlePrachtRequest({ + app, + registry, + apiRoutes: resolveApiRoutes(["/src/api/health.ts"]), + request: postCapability("/api/health", { query: "hello" }), + }); + + expect(await response.json()).toEqual({ from: "api-route" }); + }); + + it("fails closed at a custom path when another capability breaks registry resolution", async () => { + const capability = createSearchCapability({ + expose: { http: { path: "/api/find-notes" } }, + }); + const app = defineApp({ + capabilities: { + "notes.search": "./capabilities/notes-search.ts", + "notes.broken": "./capabilities/broken.ts", + }, + routes: [route("/api/find-notes", "./routes/home.tsx")], + }); + const registry: ModuleRegistry = { + routeModules: { + "./routes/home.tsx": async () => ({ Component: () => null }), + }, + capabilityModules: { + "./capabilities/notes-search.ts": async () => ({ default: capability }), + "./capabilities/broken.ts": (async () => ({ + not: "a capability", + })) as unknown as NonNullable[string], + }, + }; + + const response = await handlePrachtRequest({ + app, + registry, + request: postCapability("/api/find-notes", { query: "hello" }), + }); + + expect(response.status).toBe(500); + expect((await response.json()).error.code).toBe("internal_error"); + }); +}); + +describe("invokeCapability", () => { + it("invokes a capability directly from a loader through the same pipeline", async () => { + const { app, registry } = createApp(createSearchCapability(), { + routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })], + }); + registry.routeModules = { + "./routes/notes.tsx": async () => ({ + loader: async ({ request, context, signal }: LoaderArgs) => + invokeCapability("notes.search", { query: "from-loader" }, { request, context, signal }), + Component: () => null, + }), + }; + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/notes?_data=1"), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { ok: true, data: { notes: ["from-loader:10"] } }, + }); + }); + + it("keeps each capability host scoped to its originating request", async () => { + const createNamedCapability = (name: string) => + createSearchCapability({ + async run() { + return { notes: [name] }; + }, + }); + const appOptions = { + routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })], + }; + const first = createApp(createNamedCapability("first"), appOptions); + const second = createApp(createNamedCapability("second"), appOptions); + + let releaseFirst!: () => void; + const firstMayContinue = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstStarted!: () => void; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + + first.registry.routeModules = { + "./routes/notes.tsx": async () => ({ + loader: async ({ request, context, signal }: LoaderArgs) => { + markFirstStarted(); + await firstMayContinue; + return invokeCapability("notes.search", { query: "first" }, { request, context, signal }); + }, + Component: () => null, + }), + }; + second.registry.routeModules = { + "./routes/notes.tsx": async () => ({ + loader: async ({ request, context, signal }: LoaderArgs) => + invokeCapability("notes.search", { query: "second" }, { request, context, signal }), + Component: () => null, + }), + }; + + const firstResponsePromise = handlePrachtRequest({ + app: first.app, + registry: first.registry, + request: new Request("http://first.local/notes?_data=1"), + }); + await firstStarted; + + const secondResponse = await handlePrachtRequest({ + app: second.app, + registry: second.registry, + request: new Request("http://second.local/notes?_data=1"), + }); + releaseFirst(); + const firstResponse = await firstResponsePromise; + + expect(await firstResponse.json()).toEqual({ + data: { ok: true, data: { notes: ["first"] } }, + }); + expect(await secondResponse.json()).toEqual({ + data: { ok: true, data: { notes: ["second"] } }, + }); + }); + + it("works for private capabilities and returns validation envelopes", async () => { + const { app, registry } = createApp(createSearchCapability({ expose: undefined }), { + routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })], + }); + registry.routeModules = { + "./routes/notes.tsx": async () => ({ + loader: async ({ request, context, signal }: LoaderArgs) => + invokeCapability("notes.search", {}, { request, context, signal }), + Component: () => null, + }), + }; + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/notes?_data=1"), + }); + + const body = await response.json(); + expect(body.data.ok).toBe(false); + expect(body.data.error.code).toBe("invalid_input"); + expect(body.data.error.issues).toEqual([{ path: "/query", message: "is required" }]); + }); + + it("returns an unknown_capability envelope with suggestions", async () => { + const { app, registry } = createApp(createSearchCapability(), { + routes: [route("/notes", "./routes/notes.tsx", { id: "notes" })], + }); + registry.routeModules = { + "./routes/notes.tsx": async () => ({ + loader: async ({ request, context, signal }: LoaderArgs) => + invokeCapability("notes.serach", { query: "x" }, { request, context, signal }), + Component: () => null, + }), + }; + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/notes?_data=1"), + }); + + const body = await response.json(); + expect(body.data.ok).toBe(false); + expect(body.data.error.code).toBe("unknown_capability"); + expect(body.data.error.message).toContain("notes.search"); + }); +}); + +describe("form-encoded capability dispatch ( fallback)", () => { + function postForm( + path: string, + fields: Record, + headers: Record = {}, + ) { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", ...headers }, + body: new URLSearchParams(fields).toString(), + }); + } + + it("coerces form fields onto the input schema before validation", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: postForm("/api/capabilities/notes/search", { query: "roadmap", limit: "5" }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, data: { notes: ["roadmap:5"] } }); + }); + + it("rejects multipart files outside the capability JSON data model", async () => { + let ran = false; + const capability = createSearchCapability({ + input: { type: "object" }, + async run() { + ran = true; + return { notes: [] }; + }, + }); + const { app, registry } = createApp(capability); + const form = new FormData(); + form.set("upload", new Blob(["data"]), "notes.txt"); + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/api/capabilities/notes/search", { + method: "POST", + body: form, + }), + }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toMatchObject({ + code: "invalid_input", + issues: [{ path: "/upload", message: "must be JSON-serializable, got object" }], + }); + expect(ran).toBe(false); + }); + + it("redirects successful document form posts back to the referring page", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const response = await handlePrachtRequest({ + app, + registry, + request: postForm( + "/api/capabilities/notes/search", + { query: "roadmap" }, + { accept: "text/html", referer: "http://localhost/notes" }, + ), + }); + + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBe("http://localhost/notes"); + }); + + it("keeps the JSON envelope for failed document form posts and cross-origin referers", async () => { + const { app, registry } = createApp(createSearchCapability()); + + const invalid = await handlePrachtRequest({ + app, + registry, + request: postForm( + "/api/capabilities/notes/search", + { query: "" }, + { accept: "text/html", referer: "http://localhost/notes" }, + ), + }); + expect(invalid.status).toBe(400); + expect((await invalid.json()).error.code).toBe("invalid_input"); + + // A same-origin request whose referer points elsewhere passes CSRF but + // must not be redirected off-origin — it falls back to the JSON envelope. + const foreignReferer = await handlePrachtRequest({ + app, + registry, + request: postForm( + "/api/capabilities/notes/search", + { query: "roadmap" }, + { + accept: "text/html", + origin: "http://localhost", + referer: "https://evil.example/phish", + }, + ), + }); + expect(foreignReferer.status).toBe(200); + expect((await foreignReferer.json()).ok).toBe(true); + }); +}); + +describe("webmcp transport marker", () => { + it("audits dispatches carrying the transport header as webmcp", async () => { + const { app, registry } = createApp(createSearchCapability()); + const events: Array<{ transport: string; outcome: string }> = []; + + const response = await handlePrachtRequest({ + app, + registry, + request: new Request("http://localhost/api/capabilities/notes/search", { + method: "POST", + headers: { + "content-type": "application/json", + "x-pracht-transport": "webmcp", + }, + body: JSON.stringify({ query: "hello" }), + }), + onCapabilityAudit: (event) => events.push(event), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(1); + expect(events[0].transport).toBe("webmcp"); + expect(events[0].outcome).toBe("ok"); + }); +}); diff --git a/packages/framework/test/capability-test-host.test.ts b/packages/framework/test/capability-test-host.test.ts new file mode 100644 index 00000000..eefe3403 --- /dev/null +++ b/packages/framework/test/capability-test-host.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { defineCapability } from "../../capabilities/src/index.ts"; +import { + createCapabilityTestHost, + setCapabilityAuditHook, + setCapabilityConfirmationSecret, +} from "../src/index.ts"; +import type { CapabilityAuditEvent, PrachtAgentIdentity, PrachtCapability } from "../src/types.ts"; + +type CapabilityDefinition = Parameters[0]; + +const TEST_AGENT: PrachtAgentIdentity = { + verified: true, + agentDomain: "test-agent.example", + keyId: "test-key-id", +}; + +function createSearchCapability(overrides: Record = {}): PrachtCapability { + return defineCapability({ + title: "Search notes", + description: "Find notes.", + input: { + type: "object", + properties: { + query: { type: "string", minLength: 1 }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { + type: "object", + properties: { notes: { type: "array", items: { type: "string" } } }, + required: ["notes"], + }, + effect: "read", + expose: { http: true }, + async run({ input }) { + const typed = input as { query: string; limit: number }; + return { notes: [`${typed.query}:${typed.limit}`] }; + }, + ...overrides, + } as CapabilityDefinition) as PrachtCapability; +} + +afterEach(() => { + setCapabilityAuditHook(null); + setCapabilityConfirmationSecret(null); +}); + +describe("createCapabilityTestHost — invoke()", () => { + it("runs the full pipeline: defaults, middleware, run, envelope", async () => { + const host = createCapabilityTestHost({ + capabilities: { + "notes.search": createSearchCapability({ + middleware: ["enrich"], + async run({ + input, + context, + }: { + input: { query: string; limit: number }; + context: Record; + }) { + return { notes: [`${input.query}:${input.limit}:${context.fromMiddleware}`] }; + }, + }), + }, + middleware: { + enrich: async (args, next) => { + (args.context as Record).fromMiddleware = true; + return next(); + }, + }, + }); + + const result = await host.invoke<{ notes: string[] }>("notes.search", { query: "roadmap" }); + expect(result).toEqual({ ok: true, data: { notes: ["roadmap:10:true"] } }); + }); + + it("returns the invalid_input envelope with path-scoped issues", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + + const result = await host.invoke("notes.search", { query: "", limit: 99 }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error envelope"); + expect(result.error.code).toBe("invalid_input"); + expect(result.error.issues).toEqual([ + { path: "/query", message: "must be at least 1 character(s) long" }, + { path: "/limit", message: "must be <= 20" }, + ]); + }); + + it("answers unknown names with the unknown_capability envelope", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + + const result = await host.invoke("notes.serach", { query: "x" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error envelope"); + expect(result.error.code).toBe("unknown_capability"); + expect(result.error.message).toContain('Did you mean "notes.search"?'); + }); + + it("maps middleware short-circuits to the typed envelope", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability({ middleware: ["deny"] }) }, + middleware: { + deny: async () => new Response("denied", { status: 401 }), + }, + }); + + const result = await host.invoke("notes.search", { query: "roadmap" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected error envelope"); + expect(result.error.code).toBe("unauthorized"); + }); + + it("honors a middleware response that replaces the result after next()", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability({ middleware: ["deny-after"] }) }, + middleware: { + "deny-after": async (_args, next) => { + await next(); + return new Response("denied", { status: 403 }); + }, + }, + }); + + const result = await host.invoke("notes.search", { query: "roadmap" }); + expect(result).toEqual({ + ok: false, + error: { + code: "forbidden", + message: "Capability middleware short-circuited with status 403.", + }, + }); + }); + + it("invokes private capabilities that have no expose at all", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability({ expose: undefined }) }, + }); + + const result = await host.invoke("notes.search", { query: "roadmap" }); + expect(result.ok).toBe(true); + }); + + it("emits audit events with the server transport", async () => { + const events: CapabilityAuditEvent[] = []; + setCapabilityAuditHook((event) => events.push(event)); + + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + await host.invoke("notes.search", { query: "roadmap" }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + capability: "notes.search", + effect: "read", + transport: "server", + outcome: "ok", + status: 200, + agent: null, + }); + }); +}); + +describe("createCapabilityTestHost — request()", () => { + it("dispatches over the HTTP projection with the ok envelope", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + + const response = await host.request("notes.search", { query: "roadmap" }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, data: { notes: ["roadmap:10"] } }); + }); + + it("answers invalid input with 400 and path-scoped issues", async () => { + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + + const response = await host.request("notes.search", { query: "", limit: 99 }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error.code).toBe("invalid_input"); + expect(body.error.issues).toHaveLength(2); + }); + + it("answers unregistered and non-http capabilities with the 404 envelope", async () => { + const host = createCapabilityTestHost({ + capabilities: { + "notes.search": createSearchCapability(), + "notes.private": createSearchCapability({ expose: undefined }), + }, + }); + + for (const name of ["notes.missing", "notes.private"]) { + const response = await host.request(name, { query: "x" }); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error.code).toBe("unknown_capability"); + } + }); + + it('enforces agentPolicy "require" and accepts simulated identities', async () => { + const host = createCapabilityTestHost({ + capabilities: { + "agent.ping": createSearchCapability({ + agentPolicy: "require", + async run({ context }: { context: { agent: PrachtAgentIdentity | null } }) { + return { notes: [context.agent?.agentDomain ?? "none"] }; + }, + }), + }, + }); + + const unsigned = await host.request("agent.ping", { query: "x" }); + expect(unsigned.status).toBe(401); + expect((await unsigned.json()).error.code).toBe("agent_required"); + + const signed = await host.request("agent.ping", { query: "x" }, { agent: TEST_AGENT }); + expect(signed.status).toBe(200); + expect((await signed.json()).data.notes).toEqual(["test-agent.example"]); + }); + + it("overwrites unverified agent context when Web Bot Auth is configured", async () => { + const host = createCapabilityTestHost({ + agents: { webBotAuth: { policy: "observe" } }, + capabilities: { + "agent.ping": createSearchCapability({ + async run({ context }: { context: { agent: PrachtAgentIdentity | null } }) { + return { notes: [context.agent?.keyId ?? "none"] }; + }, + }), + }, + }); + + const response = await host.request( + "agent.ping", + { query: "x" }, + { context: { agent: { ...TEST_AGENT, keyId: "spoofed" } } }, + ); + expect(response.status).toBe(200); + expect((await response.json()).data.notes).toEqual(["none"]); + }); + + it("walks the destructive prepare/commit confirmation flow", async () => { + setCapabilityConfirmationSecret("test-host-secret"); + let purged = 0; + const host = createCapabilityTestHost({ + capabilities: { + "notes.purge": createSearchCapability({ + effect: "destructive", + async run() { + purged += 1; + return { notes: [] }; + }, + }), + }, + }); + + // Prepare: no token → 409 with a confirmation token, nothing runs. + const prepare = await host.request("notes.purge", { query: "old" }); + expect(prepare.status).toBe(409); + const prepareBody = await prepare.json(); + expect(prepareBody.error.code).toBe("confirmation_required"); + const token = prepareBody.error.confirmationToken as string; + expect(purged).toBe(0); + + // Tampered token → 403, fail closed. + const tampered = await host.request( + "notes.purge", + { query: "old" }, + { headers: { "x-pracht-confirm": `${token}x` } }, + ); + expect(tampered.status).toBe(403); + expect(purged).toBe(0); + + // Same token, different input → 403 (token is input-bound). + const mismatched = await host.request( + "notes.purge", + { query: "other" }, + { headers: { "x-pracht-confirm": token } }, + ); + expect(mismatched.status).toBe(403); + expect(purged).toBe(0); + + // Commit: identical input + token → runs. + const commit = await host.request( + "notes.purge", + { query: "old" }, + { headers: { "x-pracht-confirm": token } }, + ); + expect(commit.status).toBe(200); + expect(purged).toBe(1); + }); + + it("fails closed when no confirmation secret is configured", async () => { + const host = createCapabilityTestHost({ + capabilities: { + "notes.purge": createSearchCapability({ effect: "destructive" }), + }, + }); + + const response = await host.request("notes.purge", { query: "old" }); + expect(response.status).toBe(403); + expect((await response.json()).error.code).toBe("confirmation_unavailable"); + }); + + it("emits audit events with the http transport and the simulated agent", async () => { + const events: CapabilityAuditEvent[] = []; + setCapabilityAuditHook((event) => events.push(event)); + + const host = createCapabilityTestHost({ + capabilities: { "notes.search": createSearchCapability() }, + }); + await host.request("notes.search", { query: "roadmap" }, { agent: TEST_AGENT }); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + capability: "notes.search", + transport: "http", + outcome: "ok", + agent: TEST_AGENT, + }); + }); +}); diff --git a/packages/framework/test/devtools.test.ts b/packages/framework/test/devtools.test.ts index 444bc206..21576e49 100644 --- a/packages/framework/test/devtools.test.ts +++ b/packages/framework/test/devtools.test.ts @@ -11,6 +11,7 @@ import { defineApp, resolveApiRoutes, resolveApp, route } from "../src/app.ts"; import { buildDevtoolsHtml, DEVTOOLS_JSON_PATH } from "../src/devtools.ts"; const graphFixture: AppGraph = { + capabilities: [], api: [ { file: "/src/api/health.ts", @@ -96,6 +97,7 @@ describe("buildDevtoolsHtml", () => { it("escapes HTML in graph values", () => { const html = buildDevtoolsHtml({ api: [], + capabilities: [], routes: [ { file: "./routes/.tsx", @@ -146,10 +148,55 @@ describe("buildDevtoolsHtml", () => { }); it("renders an empty state when there are no API routes", () => { - const html = buildDevtoolsHtml({ api: [], routes: graphFixture.routes }); + const html = buildDevtoolsHtml({ api: [], capabilities: [], routes: graphFixture.routes }); expect(html).toContain("No API routes found."); }); + + it("omits the capabilities section when none are registered", () => { + const html = buildDevtoolsHtml(graphFixture); + + expect(html).not.toContain("Capabilities"); + }); + + it("renders the capabilities table when capabilities are registered", () => { + const html = buildDevtoolsHtml({ + ...graphFixture, + capabilities: [ + { + effect: "read", + hasUi: false, + httpPath: "/api/capabilities/notes/search", + input: { type: "object" }, + middleware: ["auth"], + name: "notes.search", + output: { type: "object" }, + source: "./capabilities/notes-search.ts", + title: "Search notes", + transports: ["http", "webmcp"], + }, + { + effect: "write", + hasUi: false, + httpPath: null, + input: null, + middleware: [], + name: "notes.archive", + output: null, + source: "./capabilities/notes-archive.ts", + title: "Archive note", + transports: [], + }, + ], + }); + + expect(html).toContain("Capabilities"); + expect(html).toContain("notes.search"); + expect(html).toContain("http, webmcp"); + expect(html).toContain("/api/capabilities/notes/search"); + // Unexposed capabilities are labeled private. + expect(html).toContain("private"); + }); }); describe("buildAppGraph", () => { @@ -181,6 +228,7 @@ describe("buildAppGraph", () => { }); expect(graph).toEqual({ + capabilities: [], notFound: null, api: [ { diff --git a/packages/framework/test/form-validation.test.ts b/packages/framework/test/form-validation.test.ts index b2174848..f49b9dfe 100644 --- a/packages/framework/test/form-validation.test.ts +++ b/packages/framework/test/form-validation.test.ts @@ -3,6 +3,12 @@ import { h, render } from "preact"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CAPABILITY_EFFECT_HEADER, + CAPABILITY_FORM_REDIRECT_HEADER, + CAPABILITY_FORM_REQUEST_HEADER, + CAPABILITY_SETTLED_EVENT, +} from "../../capabilities/src/index.ts"; import { Form, type ApiValidationIssue } from "../src/index.ts"; const nameSchema: StandardSchemaV1> = { @@ -47,6 +53,7 @@ describe(" validation", () => { afterEach(() => { render(null, root); root.remove(); + delete window.__PRACHT_NAVIGATE__; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -176,6 +183,211 @@ describe(" validation", () => { expect(body.get("action")).toBe("save"); }); + it("validates capability forms before submitting", async () => { + const issues: ApiValidationIssue[][] = []; + + render( + h( + Form, + { + capability: "items.create", + schema: nameSchema, + onValidationIssues: (found) => issues.push(found), + }, + h("input", { name: "name", value: "" }), + ), + root, + ); + + await submit(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(issues).toEqual([[{ in: "body", message: "Name is required", path: ["name"] }]]); + }); + + it("includes the clicked button value in capability submissions", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: {} }), { + headers: { "content-type": "application/json" }, + }), + ); + + render( + h(Form, { capability: "items.save" }, h("button", { name: "action", value: "save" }, "Save")), + root, + ); + + const form = root.querySelector("form")!; + const button = root.querySelector("button")!; + form.dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: button }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const body = fetchSpy.mock.calls[0][1].body as FormData; + expect(body.get("action")).toBe("save"); + }); + + it("keeps capability response bodies readable in onResponse", async () => { + const responseBody = { ok: true, data: { created: "pracht" } }; + fetchSpy.mockResolvedValue( + new Response(JSON.stringify(responseBody), { + status: 201, + headers: { "content-type": "application/json" }, + }), + ); + const responses: Response[] = []; + + render( + h( + Form, + { + capability: "items.create", + onResponse: (response) => responses.push(response), + }, + h("input", { name: "name", value: "pracht" }), + ), + root, + ); + + await submit(); + + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(201); + await expect(responses[0].json()).resolves.toEqual(responseBody); + }); + + it("dispatches the server-provided effect for capability revalidation", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: {} }), { + headers: { + "content-type": "application/json", + [CAPABILITY_EFFECT_HEADER]: "read", + }, + }), + ); + const settled = vi.fn(); + window.addEventListener(CAPABILITY_SETTLED_EVENT, settled, { once: true }); + + render(h(Form, { capability: "items.search" }), root); + await submit(); + + expect(settled).toHaveBeenCalledTimes(1); + expect((settled.mock.calls[0][0] as CustomEvent).detail).toEqual({ + name: "items.search", + ok: true, + effect: "read", + }); + }); + + it("uses the clicked button's formaction for capability submissions", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ ok: true, data: {} }), { + headers: { "content-type": "application/json" }, + }), + ); + + render( + h( + Form, + { capability: "items.save" }, + h("button", { formAction: "/api/capabilities/items/alternate" }, "Save elsewhere"), + ), + root, + ); + + const form = root.querySelector("form")!; + const button = root.querySelector("button")!; + form.dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: button }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledWith( + "/api/capabilities/items/alternate", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("navigates capability middleware redirects", async () => { + fetchSpy.mockResolvedValue( + new Response(null, { + status: 204, + headers: { + [CAPABILITY_FORM_REDIRECT_HEADER]: "/login?returnTo=%2Fnotes", + }, + }), + ); + const navigate = vi.fn(async () => undefined); + window.__PRACHT_NAVIGATE__ = navigate; + const results = vi.fn(); + + render(h(Form, { capability: "items.save", onCapabilityResult: results }), root); + await submit(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/api/capabilities/items/save", + expect.objectContaining({ + headers: { [CAPABILITY_FORM_REQUEST_HEADER]: "1" }, + }), + ); + expect(navigate).toHaveBeenCalledWith("/login?returnTo=%2Fnotes", { + _reloadRouteState: true, + replace: undefined, + }); + expect(results).not.toHaveBeenCalled(); + }); + + it("leaves cross-origin capability targets to native form navigation", async () => { + render( + h( + Form, + { capability: "items.save" }, + h("button", { formAction: "https://auth.example/login" }, "Sign in"), + ), + root, + ); + + const form = root.querySelector("form")!; + const button = root.querySelector("button")!; + const event = new SubmitEvent("submit", { + bubbles: true, + cancelable: true, + submitter: button, + }); + form.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(event.defaultPrevented).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("blocks unsafe capability form targets", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + h( + Form, + { capability: "items.save" }, + h("button", { formAction: "javascript:alert(1)" }, "Run"), + ), + root, + ); + + const form = root.querySelector("form")!; + const button = root.querySelector("button")!; + const event = new SubmitEvent("submit", { + bubbles: true, + cancelable: true, + submitter: button, + }); + form.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(event.defaultPrevented).toBe(true); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("unsafe URL")); + }); + it("uses the clicked button's formaction for enhanced submissions", async () => { fetchSpy.mockResolvedValue(new Response(null, { status: 200 })); diff --git a/packages/framework/test/llms-txt.test.ts b/packages/framework/test/llms-txt.test.ts new file mode 100644 index 00000000..51992ede --- /dev/null +++ b/packages/framework/test/llms-txt.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; + +import { defineCapability } from "../../capabilities/src/index.ts"; +import { defineApp, resolveApiRoutes, resolveApp, route } from "../src/app.ts"; +import { buildLlmsTxt } from "../src/llms-txt.ts"; +import type { ModuleRegistry } from "../src/types.ts"; + +function createResolvedApp() { + return resolveApp( + defineApp({ + routes: [ + route("/", "./routes/home.tsx", { render: "ssg" }), + route("/about", "./routes/about.tsx", { render: "ssr" }), + route("/blog/:slug", "./routes/blog.tsx", { render: "ssg" }), + route("/users/:id", "./routes/user.tsx", { render: "ssr" }), + route("/settings", "./routes/settings.tsx", { render: "spa" }), + ], + }), + ); +} + +function createRegistry(): ModuleRegistry { + return { + routeModules: { + "/src/routes/home.tsx": async () => ({ markdown: "# Home" }), + "/src/routes/about.tsx": async () => ({}), + "/src/routes/blog.tsx": async () => ({ + // Deliberately unsorted to prove output ordering is stable. + getStaticPaths: () => [{ slug: "hello-world" }, { slug: "getting-started" }], + }), + "/src/routes/user.tsx": async () => ({}), + "/src/routes/settings.tsx": async () => ({}), + }, + apiModules: { + "/src/api/health.ts": async () => ({ GET: () => new Response("ok") }), + "/src/api/echo.ts": async () => ({ + POST: () => new Response("ok"), + PUT: () => new Response("ok"), + }), + }, + }; +} + +const apiRoutes = resolveApiRoutes(["/src/api/health.ts", "/src/api/echo.ts"]); + +describe("buildLlmsTxt", () => { + it("renders a deterministic llms.txt from the resolved app graph", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes, + registry: createRegistry(), + title: "Pracht Test App", + description: "A test app.", + }); + + expect(output).toBe(`# Pracht Test App + +> A test app. + +## Pages + +- [/](/): supports \`Accept: text/markdown\` +- [/about](/about) +- [/blog/getting-started](/blog/getting-started) +- [/blog/hello-world](/blog/hello-world) +- [/settings](/settings) + +## API + +- [/api/echo](/api/echo): POST, PUT +- [/api/health](/api/health): GET +`); + }); + + it("prefixes links with the configured origin", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes, + registry: createRegistry(), + title: "Pracht Test App", + origin: "https://example.com/", + }); + + expect(output).toContain("- [/about](https://example.com/about)"); + expect(output).toContain("- [/api/health](https://example.com/api/health): GET"); + expect(output).not.toContain("example.com//"); + }); + + it("omits the description blockquote and excluded sections", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes, + registry: createRegistry(), + title: "Pracht Test App", + include: ["pages"], + }); + + expect(output.startsWith("# Pracht Test App\n\n## Pages\n")).toBe(true); + expect(output).not.toContain(">"); + expect(output).not.toContain("## API"); + }); + + it("skips dynamic routes without enumerable static paths", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes: [], + registry: { + // No getStaticPaths on the SSG blog route this time — it has no + // concrete URLs, so it must not appear. + routeModules: { + "/src/routes/blog.tsx": async () => ({}), + }, + }, + title: "Pracht Test App", + }); + + expect(output).not.toContain("/blog"); + expect(output).not.toContain("/users"); + expect(output).toContain("- [/about](/about)"); + }); + + it("renders without a registry (no markdown notes, no dynamic expansion)", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes, + title: "Pracht Test App", + }); + + expect(output).toContain("- [/](/)\n"); + expect(output).not.toContain("text/markdown"); + expect(output).toContain("- [/api/health](/api/health)\n"); + }); +}); + +function createCapability(overrides: Record) { + return defineCapability({ + title: "Capability", + description: "A capability.", + input: { type: "object", properties: {}, additionalProperties: false }, + output: { type: "object", properties: {} }, + effect: "read", + async run() { + return {}; + }, + ...overrides, + } as Parameters[0]); +} + +function createCapabilityFixtures() { + const app = resolveApp( + defineApp({ + capabilities: { + // Deliberately unsorted to prove name ordering is stable. + "notes.search": "./capabilities/notes-search.ts", + "notes.purge": "./capabilities/notes-purge.ts", + "notes.audit": "./capabilities/notes-audit.ts", + }, + routes: [route("/", "./routes/home.tsx", { render: "ssg" })], + }), + ); + + const capabilityModule = (capability: unknown) => + (async () => ({ default: capability })) as NonNullable< + ModuleRegistry["capabilityModules"] + >[string]; + + const registry: ModuleRegistry = { + routeModules: { "./routes/home.tsx": async () => ({}) }, + capabilityModules: { + "./capabilities/notes-search.ts": capabilityModule( + createCapability({ + description: "Find notes.", + expose: { http: true, webmcp: true }, + }), + ), + "./capabilities/notes-purge.ts": capabilityModule( + createCapability({ + description: "Delete notes.", + effect: "destructive", + expose: { http: true }, + }), + ), + // Private (no expose) — must not appear: there is no URL to call. + "./capabilities/notes-audit.ts": capabilityModule(createCapability({})), + }, + }; + + return { app, registry }; +} + +describe("buildLlmsTxt capabilities", () => { + it("lists HTTP-exposed capabilities with effect, confirmation, and description", async () => { + const { app, registry } = createCapabilityFixtures(); + const output = await buildLlmsTxt({ app, registry, title: "Pracht Test App" }); + + expect(output).toContain(`## Capabilities + +- [notes.purge](/api/capabilities/notes/purge): POST (destructive, requires confirmation) — Delete notes. +- [notes.search](/api/capabilities/notes/search): POST (read) — Find notes. +`); + expect(output).not.toContain("notes.audit"); + }); + + it("prefixes capability endpoints with the configured origin", async () => { + const { app, registry } = createCapabilityFixtures(); + const output = await buildLlmsTxt({ + app, + registry, + title: "Pracht Test App", + origin: "https://example.com", + }); + + expect(output).toContain("- [notes.search](https://example.com/api/capabilities/notes/search)"); + }); + + it("omits the section when excluded or when no registry is available", async () => { + const { app, registry } = createCapabilityFixtures(); + + const excluded = await buildLlmsTxt({ + app, + registry, + title: "Pracht Test App", + include: ["pages", "api"], + }); + expect(excluded).not.toContain("## Capabilities"); + + const withoutRegistry = await buildLlmsTxt({ app, title: "Pracht Test App" }); + expect(withoutRegistry).not.toContain("## Capabilities"); + }); + + it("omits the section when the app registers no capabilities", async () => { + const output = await buildLlmsTxt({ + app: createResolvedApp(), + apiRoutes, + registry: createRegistry(), + title: "Pracht Test App", + }); + + expect(output).not.toContain("## Capabilities"); + }); +}); diff --git a/packages/start/src/index.js b/packages/start/src/index.js index 5a7a4b9e..f3eb7658 100644 --- a/packages/start/src/index.js +++ b/packages/start/src/index.js @@ -724,8 +724,8 @@ function createViteConfig(adapter, router, tailwind) { const prachtOptions = router === "pages" - ? `{ pagesDir: "/src/pages", adapter: ${info.fn}() }` - : `{ adapter: ${info.fn}() }`; + ? `{ pagesDir: "/src/pages", adapter: ${info.fn}(), llmsTxt: {} }` + : `{ adapter: ${info.fn}(), llmsTxt: {} }`; const plugins = tailwind ? `[pracht(${prachtOptions}), tailwindcss()]` diff --git a/packages/start/test/index.test.js b/packages/start/test/index.test.js index b2098975..911c929f 100644 --- a/packages/start/test/index.test.js +++ b/packages/start/test/index.test.js @@ -305,7 +305,9 @@ describe("create-pracht", () => { expect(packageJson).toMatch(/"tailwindcss": "\^\d+\.\d+\.\d+"/); expect(packageJson).toMatch(/"@tailwindcss\/vite": "\^\d+\.\d+\.\d+"/); expect(viteConfig).toContain('import tailwindcss from "@tailwindcss/vite";'); - expect(viteConfig).toContain("plugins: [pracht({ adapter: nodeAdapter() }), tailwindcss()]"); + expect(viteConfig).toContain( + "plugins: [pracht({ adapter: nodeAdapter(), llmsTxt: {} }), tailwindcss()]", + ); expect(globalCss).toBe('@import "tailwindcss";\n'); expect(shell).toContain('import "../styles/global.css";'); expect(readme).toContain("src/styles/global.css"); @@ -331,7 +333,7 @@ describe("create-pracht", () => { const app = await readFile(join(targetDir, "src/pages/_app.tsx"), "utf-8"); expect(viteConfig).toContain( - 'plugins: [pracht({ pagesDir: "/src/pages", adapter: nodeAdapter() }), tailwindcss()]', + 'plugins: [pracht({ pagesDir: "/src/pages", adapter: nodeAdapter(), llmsTxt: {} }), tailwindcss()]', ); expect(app).toContain('import "../styles/global.css";'); expect(existsSync(join(targetDir, "src/styles/global.css"))).toBe(true); diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 66e2bf19..2cf88561 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@pracht/adapter-node": "workspace:*", + "@pracht/capabilities": "workspace:*", "@pracht/core": "workspace:*", "@pracht/preact-ssr-precompile": "workspace:*", "@preact/preset-vite": "^2.10.5", diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index ba85e63c..d72f88c2 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -11,13 +11,21 @@ import { import type { RenderMode } from "@pracht/core"; import { createEnvSafetyPlugin, PUBLIC_ENV_PREFIX, SERVER_ENV_MODULE_ID } from "./env-safety.ts"; import { + PRACHT_CAPABILITIES_MODULE_ID, PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, + PRACHT_WEBMCP_MODULE_ID, + isCapabilitiesModule, isClientModule, isIslandsClientModule, isServerModule, + isWebmcpModule, } from "./plugin-assets.ts"; +import { + createPrachtCapabilitiesClientModuleSource, + createPrachtWebmcpModuleSource, +} from "./plugin-capabilities.ts"; import { clearPagesAppSourceCache, createPrachtClientModuleSource, @@ -34,14 +42,17 @@ import { export type { RenderMode }; export type { PrachtAdapter } from "./plugin-adapter.ts"; -export type { PrachtPluginOptions } from "./plugin-options.ts"; +export type { + LlmsTxtSection, + PrachtLlmsTxtOptions, + PrachtPluginOptions, +} from "./plugin-options.ts"; export { createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtServerModuleSource, createPrachtRegistryModuleSource, } from "./plugin-codegen.ts"; -export { PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID }; export { createEnvSafetyPlugin, formatEnvLeakError, @@ -51,6 +62,18 @@ export { type EnvLeakReference, type EnvSafetyOptions, } from "./env-safety.ts"; +export { + createPrachtCapabilitiesClientModuleSource, + createPrachtWebmcpModuleSource, + extractCapabilities, +} from "./plugin-capabilities.ts"; +export { + PRACHT_CAPABILITIES_MODULE_ID, + PRACHT_CLIENT_MODULE_ID, + PRACHT_ISLANDS_CLIENT_MODULE_ID, + PRACHT_SERVER_MODULE_ID, + PRACHT_WEBMCP_MODULE_ID, +}; export function pracht(options: PrachtPluginOptions = {}): Plugin[] { const resolved = resolveOptions(options); @@ -143,6 +166,8 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { if (isIslandsClientModule(id)) return PRACHT_ISLANDS_CLIENT_MODULE_ID; if (isClientModule(id)) return PRACHT_CLIENT_MODULE_ID; if (isServerModule(id)) return PRACHT_SERVER_MODULE_ID; + if (isCapabilitiesModule(id)) return PRACHT_CAPABILITIES_MODULE_ID; + if (isWebmcpModule(id)) return PRACHT_WEBMCP_MODULE_ID; // Fail loudly when client code imports the server-only env entry. // `scan` resolutions (dep optimizer discovery) are skipped because the @@ -166,7 +191,7 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { load(id) { if (isIslandsClientModule(id)) { - return createPrachtIslandsClientModuleSource(resolved); + return createPrachtIslandsClientModuleSource(resolved, { root }); } if (isClientModule(id)) { return createPrachtClientModuleSource(resolved, { root }); @@ -174,6 +199,12 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { if (isServerModule(id)) { return createPrachtServerModuleSource(resolved, { root, isBuild }); } + if (isCapabilitiesModule(id)) { + return createPrachtCapabilitiesClientModuleSource(resolved, { root }); + } + if (isWebmcpModule(id)) { + return createPrachtWebmcpModuleSource(resolved, { root }); + } return null; }, @@ -202,7 +233,10 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { if (resolved.adapter.ownsDevServer) return; return () => { server.middlewares.use( - createDevSSRMiddleware(server, { maxBodySize: resolved.maxBodySize }), + createDevSSRMiddleware(server, { + llmsTxt: !!resolved.llmsTxt, + maxBodySize: resolved.maxBodySize, + }), ); }; }, @@ -232,6 +266,7 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { resolved.apiDir, resolved.serverDir, resolved.islandsDir, + resolved.capabilitiesDir, ]; if (dirs.some((dir) => relative.startsWith(dir))) { const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID); @@ -244,6 +279,22 @@ export function pracht(options: PrachtPluginOptions = {}): Plugin[] { const islandsMod = server.moduleGraph.getModuleById(PRACHT_ISLANDS_CLIENT_MODULE_ID); if (islandsMod) server.moduleGraph.invalidateModule(islandsMod); } + if (relative.startsWith(resolved.capabilitiesDir)) { + // Exposure metadata and schemas are baked into the generated + // browser modules — regenerate them alongside the server module. + // The client entries embed the WebMCP bootstrap conditionally on + // `hasWebmcpCapabilities()`, so they must regenerate too when a + // capability adds or drops webmcp exposure. + for (const moduleId of [ + PRACHT_CAPABILITIES_MODULE_ID, + PRACHT_WEBMCP_MODULE_ID, + PRACHT_CLIENT_MODULE_ID, + PRACHT_ISLANDS_CLIENT_MODULE_ID, + ]) { + const capabilityMod = server.moduleGraph.getModuleById(moduleId); + if (capabilityMod) server.moduleGraph.invalidateModule(capabilityMod); + } + } } }, }; @@ -401,6 +452,7 @@ function createPrachtOptimizeDepsEntries(resolved: ResolvedPrachtPluginOptions): `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`, `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`, `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`, + `${toOptimizeDepsEntry(resolved.capabilitiesDir)}/**/*.{ts,js,tsx,jsx}`, ]; return [...new Set(entries.filter(Boolean))]; diff --git a/packages/vite-plugin/src/plugin-assets.ts b/packages/vite-plugin/src/plugin-assets.ts index 168008d7..3139df71 100644 --- a/packages/vite-plugin/src/plugin-assets.ts +++ b/packages/vite-plugin/src/plugin-assets.ts @@ -5,6 +5,8 @@ import { stripPrachtClientModuleQuery } from "./client-module-query.ts"; export const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client"; export const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server"; export const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client"; +export const PRACHT_CAPABILITIES_MODULE_ID = "virtual:pracht/capabilities"; +export const PRACHT_WEBMCP_MODULE_ID = "virtual:pracht/webmcp"; // Browser-safe path alias — the colon in "virtual:" is parsed as a protocol // scheme by browsers, so we serve the client module from a plain path. @@ -125,3 +127,11 @@ export function isIslandsClientModule(id: string): boolean { id.endsWith(PRACHT_ISLANDS_CLIENT_MODULE_ID) ); } + +export function isCapabilitiesModule(id: string): boolean { + return id === PRACHT_CAPABILITIES_MODULE_ID || id.endsWith(PRACHT_CAPABILITIES_MODULE_ID); +} + +export function isWebmcpModule(id: string): boolean { + return id === PRACHT_WEBMCP_MODULE_ID || id.endsWith(PRACHT_WEBMCP_MODULE_ID); +} diff --git a/packages/vite-plugin/src/plugin-capabilities.ts b/packages/vite-plugin/src/plugin-capabilities.ts new file mode 100644 index 00000000..270d866b --- /dev/null +++ b/packages/vite-plugin/src/plugin-capabilities.ts @@ -0,0 +1,389 @@ +/** + * Build-time capability projection for the browser. + * + * The client never loads capability modules (they are server-only), so the + * `virtual:pracht/capabilities` and `virtual:pracht/webmcp` modules are + * generated from static analysis of the app manifest and the registered + * capability sources — the same approach the plugin already uses for + * hydration-mode excludes. Only serializable metadata crosses the boundary: + * capability names, HTTP endpoints, effects, and (for WebMCP tools) + * description and input schema. + * + * The static analyzer itself lives in `@pracht/capabilities/static` and is + * shared with `pracht verify`, so the build and verification can never + * disagree about what is analyzable. Constraint it imposes: a capability's + * `expose`, HTTP-projected `effect`, and WebMCP `input` values must be inline + * literals (no imported constants or spreads) — the extractor parses the + * literal text as data. + * Extraction failures fail the build with a pointer to the offending file + * rather than silently dropping an endpoint. + */ + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { + CAPABILITY_SETTLED_EVENT, + CAPABILITY_TRANSPORT_HEADER, + capabilityHttpPath, + CONFIRMATION_HEADER, + isValidCapabilityHttpPath, +} from "@pracht/capabilities"; +import { + evaluateLiteral, + extractCapabilityRegistrations, + extractDefineCapabilityArgs, + scanTopLevelProperties, +} from "@pracht/capabilities/static"; +import { resolveOptions, type PrachtPluginOptions } from "./plugin-options.ts"; + +export { extractCapabilityRegistrations }; + +export interface ExtractedCapability { + name: string; + /** Manifest-relative module path, e.g. "./capabilities/notes-search.ts". */ + file: string; + description: string; + effect: string | null; + httpPath: string | null; + webmcp: boolean; + inputSchema: Record | null; +} + +/** + * Extract capability registrations (name → module path) from the app + * manifest source and their exposure metadata from each capability source. + * Pages-router apps have no manifest, so capabilities are manifest-mode only. + */ +export function extractCapabilities( + options: PrachtPluginOptions = {}, + root: string = process.cwd(), +): ExtractedCapability[] { + const resolved = resolveOptions(options); + if (resolved.pagesDir) return []; + + const appFileAbs = resolve(root, resolved.appFile.replace(/^\//, "")); + let manifestSource: string; + try { + manifestSource = readFileSync(appFileAbs, "utf-8"); + } catch { + return []; + } + + const registrations = extractCapabilityRegistrations(manifestSource); + if (registrations.length === 0) return []; + + const appDir = dirname(appFileAbs); + return registrations.map(({ name, file }) => { + // Root-relative refs (`/src/capabilities/x.ts`) resolve against the Vite + // root, matching the runtime registry loader; everything else is relative + // to the app manifest's directory. + const capabilityFileAbs = file.startsWith("/") + ? resolve(root, file.replace(/^\//, "")) + : resolve(appDir, file); + let source: string; + try { + source = readFileSync(capabilityFileAbs, "utf-8"); + } catch { + throw new Error( + `[pracht] Capability "${name}" references missing file ${JSON.stringify(file)}.`, + ); + } + return extractCapabilityMetadata(name, file, source); + }); +} + +function extractCapabilityMetadata( + name: string, + file: string, + source: string, +): ExtractedCapability { + const args = extractDefineCapabilityArgs(source); + if (!args) { + throw new Error( + `[pracht] Capability "${name}" (${file}) does not contain a ` + + "defineCapability({ ... }) call the build can analyze.", + ); + } + + const properties = scanTopLevelProperties(args); + const exposeText = properties.get("expose"); + if (!exposeText) { + // Private capability: server-only, nothing to project to the client. + return { + name, + file, + description: "", + effect: null, + httpPath: null, + webmcp: false, + inputSchema: null, + }; + } + + const expose = evaluateLiteral(exposeText); + if (!isPlainObject(expose)) { + throw new Error( + `[pracht] Capability "${name}" (${file}): "expose" must be an inline object ` + + "literal so the client projection can be generated at build time.", + ); + } + + const http = expose.http; + let httpPath: string | null = null; + if (http === true) { + httpPath = capabilityHttpPath(name); + } else if (isPlainObject(http)) { + httpPath = typeof http.path === "string" ? http.path : capabilityHttpPath(name); + } + if (httpPath && !isValidCapabilityHttpPath(httpPath)) { + throw new Error( + `[pracht] Capability "${name}" (${file}): HTTP exposure "path" must be an exact ` + + 'same-origin pathname starting with "/".', + ); + } + + const webmcp = expose.webmcp === true; + if (webmcp && !httpPath) { + throw new Error(`[pracht] Capability "${name}" (${file}): expose.webmcp requires expose.http.`); + } + + let description = ""; + const descriptionText = properties.get("description"); + if (descriptionText) { + const value = evaluateLiteral(descriptionText); + if (typeof value === "string") description = value; + } + + let effect: string | null = null; + const effectText = properties.get("effect"); + if (effectText) { + const value = evaluateLiteral(effectText); + if (typeof value === "string") effect = value; + } + if (httpPath && effect !== "read" && effect !== "write" && effect !== "destructive") { + throw new Error( + `[pracht] Capability "${name}" (${file}) is exposed via HTTP, but its "effect" ` + + 'could not be extracted at build time. HTTP-exposed capabilities must declare "effect" ' + + 'as an inline "read", "write", or "destructive" string literal.', + ); + } + + let inputSchema: Record | null = null; + if (webmcp) { + const inputText = properties.get("input"); + const value = inputText ? evaluateLiteral(inputText) : undefined; + if (!isPlainObject(value)) { + throw new Error( + `[pracht] Capability "${name}" (${file}) is exposed via WebMCP, but its "input" ` + + "schema could not be extracted at build time. WebMCP-exposed capabilities must " + + "declare their input schema as an inline object literal.", + ); + } + inputSchema = value; + } + + return { name, file, description, effect, httpPath, webmcp, inputSchema }; +} + +/** + * Generate `virtual:pracht/capabilities` — the browser-side `callCapability` + * helper plus the endpoint map for http-exposed capabilities. Side-effect + * free, so it costs zero bytes unless application code imports it. + * + * After every call settles, the helper announces itself on + * CAPABILITY_SETTLED_EVENT with the capability's effect class; the framework + * runtime revalidates route data for successful non-`read` calls (opt out + * per call via `{ revalidate: false }`). + */ +export function createPrachtCapabilitiesClientModuleSource( + options: PrachtPluginOptions = {}, + buildOptions: { root?: string } = {}, +): string { + const capabilities = extractCapabilities(options, buildOptions.root); + const endpoints: Record = {}; + for (const capability of capabilities) { + if (capability.httpPath) { + endpoints[capability.name] = { + method: "POST", + path: capability.httpPath, + effect: capability.effect, + }; + } + } + + return [ + "// Generated by @pracht/vite-plugin from the app manifest capability registrations.", + "// Contains only http-exposed capability names, endpoints, and effects —", + "// capability modules themselves are server-only and never reach the client graph.", + `const endpoints = ${JSON.stringify(endpoints)};`, + "", + "export const capabilityEndpoints = endpoints;", + "", + "async function dispatchCapability(endpoint, input, opts) {", + " let response;", + " try {", + " const headers = new Headers(opts && opts.headers);", + ' headers.set("content-type", "application/json");', + " if (opts && opts.confirm) {", + ` headers.set(${JSON.stringify(CONFIRMATION_HEADER)}, opts.confirm);`, + " }", + " response = await fetch(endpoint.path, {", + " method: endpoint.method,", + " headers,", + " body: JSON.stringify(input === undefined ? {} : input),", + ' credentials: "same-origin",', + " signal: opts && opts.signal,", + " });", + " } catch (error) {", + " return {", + " ok: false,", + ' error: { code: "network_error", message: String((error && error.message) || error) },', + " };", + " }", + " try {", + " return await response.json();", + " } catch {", + " return {", + " ok: false,", + " error: {", + ' code: "invalid_response",', + " message: `Capability endpoint returned a non-JSON response (status ${response.status}).`,", + " },", + " };", + " }", + "}", + "", + "export async function callCapability(name, input, opts) {", + " const endpoint = endpoints[name];", + " if (!endpoint) {", + " return {", + " ok: false,", + " error: {", + ' code: "unknown_capability",', + ' message: `No HTTP-exposed capability named "${name}" is registered.`,', + " },", + " };", + " }", + " const result = await dispatchCapability(endpoint, input, opts);", + " // Announce the settled call so the route runtime can revalidate after", + " // successful non-read effects. Best-effort — never breaks the call.", + " try {", + ' if (typeof window !== "undefined") {', + ` window.dispatchEvent(new CustomEvent(${JSON.stringify(CAPABILITY_SETTLED_EVENT)}, {`, + " detail: {", + " name,", + " effect: endpoint.effect,", + " ok: result && result.ok === true,", + " revalidate: opts && opts.revalidate === false ? false : undefined,", + " },", + " }));", + " }", + " } catch {}", + " return result;", + "}", + "", + ].join("\n"); +} + +/** + * Generate `virtual:pracht/webmcp` — the disposable WebMCP registration shim. + * One page tool per `expose.webmcp` capability; `execute` dispatches through + * `callCapability`, so the user's session authenticates the call and all + * validation/middleware/policy stays server-side. Each dispatch carries the + * transport marker header so audit events can attribute it to WebMCP. + * + * Targets the Chrome origin-trial API: `document.modelContext.registerTool()` + * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location + * and is kept as a fallback). No-ops silently when the API is absent. + */ +export function createPrachtWebmcpModuleSource( + options: PrachtPluginOptions = {}, + buildOptions: { root?: string } = {}, +): string { + const capabilities = extractCapabilities(options, buildOptions.root).filter( + (capability) => capability.webmcp, + ); + + const tools = capabilities.map((capability) => ({ + name: capability.name, + description: capability.description, + effect: capability.effect, + inputSchema: capability.inputSchema, + })); + + return [ + "// Generated by @pracht/vite-plugin — WebMCP page-tool registration shim.", + 'import { callCapability } from "virtual:pracht/capabilities";', + "", + `const tools = ${JSON.stringify(tools)};`, + `const transportHeaders = { ${JSON.stringify(CAPABILITY_TRANSPORT_HEADER)}: "webmcp" };`, + "", + "export function registerPrachtWebmcpTools() {", + " const modelContext =", + ' (typeof document !== "undefined" && document.modelContext) ||', + ' (typeof navigator !== "undefined" && navigator.modelContext) ||', + " null;", + ' if (!modelContext || typeof modelContext.registerTool !== "function") {', + " return false;", + " }", + " for (const tool of tools) {", + " try {", + " const registration = modelContext.registerTool({", + " name: tool.name,", + " description: tool.description,", + " inputSchema: tool.inputSchema,", + ' annotations: { readOnlyHint: tool.effect === "read" },', + " async execute(input) {", + " const result = await callCapability(tool.name, input, { headers: transportHeaders });", + ' return { content: [{ type: "text", text: JSON.stringify(result) }] };', + " },", + " });", + ' if (registration && typeof registration.catch === "function") {', + " registration.catch(() => {});", + " }", + " } catch {", + " // Origin-trial API surface may shift; a failed registration must", + " // never break the page.", + " }", + " }", + " return true;", + "}", + "", + "registerPrachtWebmcpTools();", + "", + ].join("\n"); +} + +/** + * Snippet appended to the client entry / islands bootstrap when at least one + * capability opts into WebMCP. Feature-detects before importing so browsers + * without the origin trial never pay for the shim chunk. + */ +export function createWebmcpBootstrapSource(): string[] { + return [ + "// WebMCP page tools — loaded only when the browser exposes the API.", + "if (", + ' typeof document !== "undefined" &&', + ' (document.modelContext || (typeof navigator !== "undefined" && navigator.modelContext))', + ") {", + ' import("virtual:pracht/webmcp").catch(() => {});', + "}", + "", + ]; +} + +export function hasWebmcpCapabilities( + options: PrachtPluginOptions = {}, + root: string = process.cwd(), +): boolean { + try { + return extractCapabilities(options, root).some((capability) => capability.webmcp); + } catch { + // Extraction errors surface when the virtual modules are generated. + return true; + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/vite-plugin/src/plugin-codegen.ts b/packages/vite-plugin/src/plugin-codegen.ts index e3f4fe5b..44790991 100644 --- a/packages/vite-plugin/src/plugin-codegen.ts +++ b/packages/vite-plugin/src/plugin-codegen.ts @@ -13,6 +13,7 @@ import { type ResolvedPrachtPluginOptions, } from "./plugin-options.ts"; import { createRouteLoaderHints } from "./route-loader-hints.ts"; +import { createWebmcpBootstrapSource, hasWebmcpCapabilities } from "./plugin-capabilities.ts"; const ROUTE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".md", ".mdx", ".tsrx"]); const NON_FULL_HYDRATION_RE = /hydration\s*:\s*["'](?:islands|none)["']/; @@ -250,6 +251,9 @@ export function createPrachtClientModuleSource( " });", "}", "", + // WebMCP page-tool registration — only emitted when at least one + // capability opts in, so apps without WebMCP exposure ship zero extra bytes. + ...(hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []), ].join("\n"); } @@ -259,7 +263,10 @@ export function createPrachtClientModuleSource( * manifest, the router, or the full client runtime: it only scans the DOM * for island markers and hydrates the islands present on the page. */ -export function createPrachtIslandsClientModuleSource(options: PrachtPluginOptions = {}): string { +export function createPrachtIslandsClientModuleSource( + options: PrachtPluginOptions = {}, + buildOptions: { root?: string } = {}, +): string { const resolved = resolveOptions(options); const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`; @@ -270,6 +277,9 @@ export function createPrachtIslandsClientModuleSource(options: PrachtPluginOptio "", "hydrateIslands({ modules: islandModules });", "", + // Islands pages skip the full client runtime, so the bootstrap pulls in + // the WebMCP shim itself when a capability opts in. + ...(hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []), ].join("\n"); } @@ -288,14 +298,18 @@ export function createPrachtServerModuleSource( ? readClientBuildAssets(buildOptions.root) : { clientEntryUrl: null, islandsEntryUrl: null, cssManifest: {}, jsManifest: {} }; const adapter = resolved.adapter; + const llmsTxtConfig = resolveLlmsTxtConfig(resolved, buildOptions.root); // The adapter tells us what extra imports it needs (e.g. handlePrachtRequest). // Always import prerenderApp so the CLI uses the same bundled copy of // @pracht/core/server (and therefore the same Preact context instances) as the // route/shell modules — avoids dual-copy issues during SSG prerendering. - const prachtImports = adapter?.serverImports + let prachtImports = adapter?.serverImports ? adapter.serverImports + '\nimport { prerenderApp } from "@pracht/core/server";' : 'import { resolveApp, resolveApiRoutes, prerenderApp } from "@pracht/core/server";'; + if (llmsTxtConfig) { + prachtImports += '\nimport { buildLlmsTxt } from "@pracht/core/server";'; + } const appImport = isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) @@ -336,6 +350,16 @@ export function createPrachtServerModuleSource( `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`, `export const budgets = ${JSON.stringify(resolved.budgets)};`, "export { prerenderApp };", + ...(llmsTxtConfig + ? [ + "// llms.txt (https://llmstxt.org) generated from the resolved app graph.", + "// `pracht build` writes it to dist/client/llms.txt; the dev SSR", + "// middleware serves it at /llms.txt.", + `const llmsTxtConfig = ${JSON.stringify(llmsTxtConfig)};`, + "export const generateLlmsTxt = () =>", + " buildLlmsTxt({ ...llmsTxtConfig, apiRoutes, app: resolvedApp, registry });", + ] + : []), "", ]; @@ -346,6 +370,41 @@ export function createPrachtServerModuleSource( return source.join("\n"); } +interface ResolvedLlmsTxtConfig { + title: string; + description?: string; + origin?: string; + include?: string[]; +} + +/** + * Fill llms.txt title/description from the app's package.json when the user + * did not set them explicitly. Returns null when the feature is disabled so + * the server module codegen stays byte-for-byte unchanged. + */ +function resolveLlmsTxtConfig( + resolved: ResolvedPrachtPluginOptions, + root = process.cwd(), +): ResolvedLlmsTxtConfig | null { + if (!resolved.llmsTxt) return null; + + let pkg: { name?: unknown; description?: unknown } = {}; + try { + pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8")); + } catch {} + + const config: ResolvedLlmsTxtConfig = { + title: resolved.llmsTxt.title ?? (typeof pkg.name === "string" && pkg.name ? pkg.name : "App"), + }; + const description = + resolved.llmsTxt.description ?? + (typeof pkg.description === "string" && pkg.description ? pkg.description : undefined); + if (description) config.description = description; + if (resolved.llmsTxt.origin) config.origin = resolved.llmsTxt.origin; + if (resolved.llmsTxt.include) config.include = resolved.llmsTxt.include; + return config; +} + function createApplyRouteLoaderHintsSource(): string[] { return [ "function applyRouteLoaderHints(resolvedApp, routeLoaderHints) {", @@ -415,6 +474,7 @@ export function createPrachtRegistryModuleSource(options: PrachtPluginOptions = `export const middlewareModules = import.meta.glob(${JSON.stringify(`${resolved.middlewareDir}/**/*.{ts,tsx,js,jsx}`)});`, `export const apiModules = import.meta.glob(${JSON.stringify(`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`)});`, `export const dataModules = import.meta.glob(${JSON.stringify(`${resolved.serverDir}/**/*.{ts,js,tsx,jsx}`)});`, + `export const capabilityModules = import.meta.glob(${JSON.stringify(`${resolved.capabilitiesDir}/**/*.{ts,js,tsx,jsx}`)});`, "", "export const registry = {", " routeModules,", @@ -422,6 +482,7 @@ export function createPrachtRegistryModuleSource(options: PrachtPluginOptions = " middlewareModules,", " apiModules,", " dataModules,", + " capabilityModules,", "};", ].join("\n"); } diff --git a/packages/vite-plugin/src/plugin-dev-ssr.ts b/packages/vite-plugin/src/plugin-dev-ssr.ts index 0979e346..126a4c8a 100644 --- a/packages/vite-plugin/src/plugin-dev-ssr.ts +++ b/packages/vite-plugin/src/plugin-dev-ssr.ts @@ -1,8 +1,9 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import type { Connect, ViteDevServer } from "vite"; import type { PrachtPhaseTimings, ResolvedApiRoute, ResolvedPrachtApp } from "@pracht/core"; +import { resolveRegistryModule } from "@pracht/core"; import { CLIENT_BROWSER_PATH, ISLANDS_CLIENT_BROWSER_PATH, @@ -14,13 +15,30 @@ const DEFAULT_MAX_BODY_SIZE = 1024 * 1024; // 1 MiB export const DEVTOOLS_PATH = "/_pracht"; export const DEVTOOLS_JSON_PATH = "/_pracht.json"; +export const LLMS_TXT_PATH = "/llms.txt"; export function createDevSSRMiddleware( server: ViteDevServer, - options: { maxBodySize?: number } = {}, + options: { maxBodySize?: number; llmsTxt?: boolean } = {}, ): Connect.NextHandleFunction { const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE; let warnedDevtoolsCollision = false; + let warnedLlmsTxtCollision = false; + + // A hand-written public/llms.txt and the generated one disagree about who + // wins: Vite's publicDir middleware serves the static file in dev (before + // this handler runs), while `pracht build` overwrites it with generated + // content. Warn once so the divergence is not silent. + if (options.llmsTxt && typeof server.config.publicDir === "string") { + const publicLlmsTxt = join(server.config.publicDir, "llms.txt"); + if (existsSync(publicLlmsTxt)) { + server.config.logger.warn( + `[pracht] Both public/llms.txt and the pracht({ llmsTxt }) option are present. ` + + `Dev serves the static public/llms.txt, but "pracht build" overwrites it with the ` + + `generated content. Remove one to avoid a dev/production mismatch.`, + ); + } + } return async (req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction) => { const url = req.url ?? "/"; const requestUrl = new URL(url, "http://localhost"); @@ -59,6 +77,31 @@ export function createDevSSRMiddleware( return; } + // `/llms.txt` is served from the live app graph when the plugin's + // `llmsTxt` option is enabled — the same content `pracht build` writes + // to dist/client/llms.txt. + if ( + options.llmsTxt && + requestUrl.pathname === LLMS_TXT_PATH && + BODYLESS_METHODS.has((req.method ?? "GET").toUpperCase()) && + typeof serverMod.generateLlmsTxt === "function" + ) { + if (!warnedLlmsTxtCollision && matchesResolvedRoute(LLMS_TXT_PATH, routeMatchers)) { + warnedLlmsTxtCollision = true; + server.config.logger.warn( + `[pracht] An app route matches ${LLMS_TXT_PATH}, which is reserved by the ` + + `pracht({ llmsTxt }) option. The generated llms.txt wins; disable the option ` + + `to serve the app route instead.`, + ); + } + + const llmsTxt: string = await serverMod.generateLlmsTxt(); + res.statusCode = 200; + res.setHeader("content-type", "text/plain; charset=utf-8"); + res.end(llmsTxt); + return; + } + if (shouldBypassDevSSR(requestUrl, req, routeMatchers)) { return next(); } @@ -93,9 +136,16 @@ export function createDevSSRMiddleware( // A 404 from the runtime normally falls through to Vite (which has // already had its shot at static files, since this middleware is - // installed after Vite's own). Apps that declare a `notFound` page get - // that page rendered here instead — same as in production. - if (response.status === 404 && !routeMatchers.app?.notFound) { + // installed after Vite's own). Two exceptions are served as-is: apps + // that declare a `notFound` page get that page rendered here — same as + // in production — and JSON 404s are typed API responses (route-state, + // capability envelopes) that must reach the client untouched. + const responseContentType = response.headers.get("content-type") ?? ""; + if ( + response.status === 404 && + !responseContentType.includes("application/json") && + !routeMatchers.app?.notFound + ) { return next(); } @@ -136,10 +186,25 @@ async function serveDevtools( }, ): Promise { const devtools = await server.ssrLoadModule("@pracht/core/devtools"); + // Manifest capability paths are relative to the app file (e.g. + // `./capabilities/notes-search.ts`), which a bare ssrLoadModule resolves + // against the Vite root and fails to find. Resolve through the virtual + // server module's registry first (matching `pracht inspect`), falling back + // to a direct load for absolute/root-relative paths. + const serverModule = (await server.ssrLoadModule(PRACHT_SERVER_MODULE_ID)) as { + registry?: { capabilityModules?: Record Promise> }; + }; + const capabilityModules = serverModule.registry?.capabilityModules; const graph = await devtools.buildAppGraph({ apiRoutes: options.apiRoutes, app: options.app, - loadModule: (file: string) => server.ssrLoadModule(file), + loadModule: async (file: string) => { + const viaRegistry = await resolveRegistryModule>( + capabilityModules, + file, + ); + return viaRegistry ?? server.ssrLoadModule(file); + }, readSource: (file: string) => readFileSync(resolve(server.config.root, `.${file}`), "utf-8"), }); diff --git a/packages/vite-plugin/src/plugin-options.ts b/packages/vite-plugin/src/plugin-options.ts index c39012a2..b773c5ba 100644 --- a/packages/vite-plugin/src/plugin-options.ts +++ b/packages/vite-plugin/src/plugin-options.ts @@ -3,6 +3,25 @@ import type { PreactSsrPrecompileOptions } from "@pracht/preact-ssr-precompile"; import type { EnvSafetyOptions } from "./env-safety.ts"; import { createDefaultNodeAdapter, type PrachtAdapter } from "./plugin-adapter.ts"; +export type LlmsTxtSection = "pages" | "api" | "capabilities"; + +export interface PrachtLlmsTxtOptions { + /** H1 title. Defaults to the app's package.json `name`. */ + title?: string; + /** + * Blockquote summary under the title. Defaults to the app's package.json + * `description`; omitted when neither is set. + */ + description?: string; + /** + * Origin (e.g. "https://example.com") prepended to every link so llms.txt + * contains absolute URLs. Links stay root-relative when omitted. + */ + origin?: string; + /** Sections to emit. Defaults to ["pages", "api", "capabilities"]. */ + include?: LlmsTxtSection[]; +} + export interface PrachtPluginOptions { appFile?: string; routesDir?: string; @@ -15,6 +34,11 @@ export interface PrachtPluginOptions { * `hydration: "islands"` routes. Defaults to "/src/islands". */ islandsDir?: string; + /** + * Directory containing capability modules registered in the app manifest + * via `capabilities: { ... }`. Defaults to "/src/capabilities". + */ + capabilitiesDir?: string; adapter?: PrachtAdapter; /** Enable file-system pages routing by pointing to the pages directory (e.g. "/src/pages"). */ pagesDir?: string; @@ -44,6 +68,12 @@ export interface PrachtPluginOptions { * variables, or `false` to disable the check entirely. */ envSafety?: false | EnvSafetyOptions; + /** + * Opt into emitting an llms.txt file (https://llmstxt.org) generated from + * the resolved app graph. `pracht build` writes `dist/client/llms.txt` and + * the dev server serves `/llms.txt` live. Disabled by default. + */ + llmsTxt?: false | PrachtLlmsTxtOptions; } export type ResolvedPrachtPluginOptions = Required; @@ -56,6 +86,7 @@ const DEFAULTS: ResolvedPrachtPluginOptions = { apiDir: "/src/api", serverDir: "/src/server", islandsDir: "/src/islands", + capabilitiesDir: "/src/capabilities", adapter: createDefaultNodeAdapter(), pagesDir: "", pagesDefaultRender: "ssr", @@ -64,6 +95,7 @@ const DEFAULTS: ResolvedPrachtPluginOptions = { budgets: {}, precompileSsrJsx: false, envSafety: {}, + llmsTxt: false, }; export function resolveOptions(options: PrachtPluginOptions): ResolvedPrachtPluginOptions { @@ -71,6 +103,11 @@ export function resolveOptions(options: PrachtPluginOptions): ResolvedPrachtPlug ...DEFAULTS, ...options, }; + // An explicit `llmsTxt: undefined` (permitted by the optional type) would + // spread over the `false` default — treat it as disabled, not invalid. + if (resolved.llmsTxt === undefined) { + resolved.llmsTxt = false; + } if (!Number.isInteger(resolved.prerenderConcurrency) || resolved.prerenderConcurrency <= 0) { throw new Error("pracht({ prerenderConcurrency }) expects a positive integer."); } @@ -78,9 +115,29 @@ export function resolveOptions(options: PrachtPluginOptions): ResolvedPrachtPlug throw new Error("pracht({ maxBodySize }) expects a positive integer number of bytes."); } validateBudgets(resolved.budgets); + validateLlmsTxt(resolved.llmsTxt); return resolved; } +const LLMS_TXT_SECTIONS = new Set(["pages", "api", "capabilities"]); + +function validateLlmsTxt(llmsTxt: false | PrachtLlmsTxtOptions): void { + if (llmsTxt === false) return; + if (typeof llmsTxt !== "object" || llmsTxt === null) { + throw new Error("pracht({ llmsTxt }) expects false or an options object."); + } + if (llmsTxt.include !== undefined) { + const isValid = + Array.isArray(llmsTxt.include) && + llmsTxt.include.every((section) => LLMS_TXT_SECTIONS.has(section)); + if (!isValid) { + throw new Error( + `pracht({ llmsTxt: { include } }) expects an array of "pages", "api", and/or "capabilities", got ${JSON.stringify(llmsTxt.include)}.`, + ); + } + } +} + function validateBudgets(budgets: Record): void { for (const [key, value] of Object.entries(budgets)) { if (key !== "*" && !key.startsWith("/")) { diff --git a/packages/vite-plugin/test/capabilities-codegen.test.ts b/packages/vite-plugin/test/capabilities-codegen.test.ts new file mode 100644 index 00000000..0f7b1eb6 --- /dev/null +++ b/packages/vite-plugin/test/capabilities-codegen.test.ts @@ -0,0 +1,385 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { CapabilityErrorPayload } from "virtual:pracht/capabilities"; + +import { + createPrachtCapabilitiesClientModuleSource, + createPrachtWebmcpModuleSource, + extractCapabilities, +} from "../src/plugin-capabilities.ts"; +import { + createPrachtClientModuleSource, + createPrachtIslandsClientModuleSource, +} from "../src/plugin-codegen.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +const SEARCH_CAPABILITY = `import { defineCapability } from "@pracht/capabilities"; + +// The interface between the import and the call guards the extractor against +// matching the import binding or the interface braces. +interface SearchInput { + query: string; + limit: number; +} + +export default defineCapability({ + title: "Search notes", + description: "Find notes whose title matches the query.", + input: { + type: "object", + properties: { + // Text to search for. + query: { type: "string", minLength: 1, description: "Text to search for." }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }, + output: { type: "object", properties: { notes: { type: "array" } }, required: ["notes"] }, + effect: "read", + expose: { + http: true, + webmcp: true, + }, + async run({ input }) { + return { notes: [input.query] }; + }, +}); +`; + +const CREATE_CAPABILITY = `import { defineCapability } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Create note", + description: "Add a note.", + input: { type: "object", properties: { title: { type: "string" } }, required: ["title"] }, + output: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + effect: "write", + expose: { http: { path: "/api/create-note" } }, + async run() { + return { id: "n1" }; + }, +}); +`; + +const PRIVATE_CAPABILITY = `import { defineCapability } from "@pracht/capabilities"; + +export default defineCapability({ + title: "Private op", + description: "Server-only.", + input: { type: "object" }, + output: { type: "object" }, + effect: "read", + async run() { + return {}; + }, +}); +`; + +function createFixture(options: { + capabilities?: Record; + manifestCapabilitiesBlock?: string; +}): string { + const root = mkdtempSync(join(tmpdir(), "pracht-capabilities-codegen-")); + tempDirs.push(root); + mkdirSync(join(root, "src/capabilities"), { recursive: true }); + mkdirSync(join(root, "src/routes"), { recursive: true }); + + const capabilities = options.capabilities ?? {}; + for (const [file, source] of Object.entries(capabilities)) { + writeFileSync(join(root, "src/capabilities", file), source, "utf-8"); + } + + const capabilitiesBlock = + options.manifestCapabilitiesBlock ?? + (Object.keys(capabilities).length > 0 + ? `capabilities: {\n${Object.keys(capabilities) + .map( + (file) => + ` "${file.replace(/\.ts$/, "").replace(/-/g, ".")}": () => import("./capabilities/${file}"),`, + ) + .join("\n")}\n },` + : ""); + + writeFileSync( + join(root, "src/routes.ts"), + [ + 'import { defineApp, route } from "@pracht/core";', + "", + "export const app = defineApp({", + ` ${capabilitiesBlock}`, + " routes: [", + ' route("/", () => import("./routes/home.tsx"), { id: "home" }),', + " ],", + "});", + "", + ].join("\n"), + "utf-8", + ); + writeFileSync( + join(root, "src/routes/home.tsx"), + "export function Component() { return null; }\n", + ); + + return root; +} + +describe("extractCapabilities", () => { + it("extracts registrations, exposure, and schemas from source", () => { + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY, + "notes-create.ts": CREATE_CAPABILITY, + "notes-private.ts": PRIVATE_CAPABILITY, + }, + }); + + const capabilities = extractCapabilities({}, root); + expect(capabilities).toHaveLength(3); + + const search = capabilities.find((entry) => entry.name === "notes.search"); + expect(search).toMatchObject({ + httpPath: "/api/capabilities/notes/search", + webmcp: true, + description: "Find notes whose title matches the query.", + effect: "read", + }); + // The full JSON Schema survives extraction for WebMCP registration. + expect(search?.inputSchema).toEqual({ + type: "object", + properties: { + query: { type: "string", minLength: 1, description: "Text to search for." }, + limit: { type: "integer", minimum: 1, maximum: 20, default: 10 }, + }, + required: ["query"], + additionalProperties: false, + }); + + expect(capabilities.find((entry) => entry.name === "notes.create")).toMatchObject({ + httpPath: "/api/create-note", + webmcp: false, + inputSchema: null, + }); + + expect(capabilities.find((entry) => entry.name === "notes.private")).toMatchObject({ + httpPath: null, + webmcp: false, + }); + }); + + it("returns an empty list when the manifest registers no capabilities", () => { + const root = createFixture({}); + expect(extractCapabilities({}, root)).toEqual([]); + }); + + it("fails loudly when a webmcp capability schema is not an inline literal", () => { + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY.replace( + /input: \{[\s\S]*?\n \},/, + "input: sharedSchema,", + ), + }, + }); + + expect(() => extractCapabilities({}, root)).toThrow(/inline object literal/); + }); + + it("fails when an HTTP capability effect is not an inline literal", () => { + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY.replace('effect: "read",', "effect: READ_EFFECT,"), + }, + }); + + expect(() => extractCapabilities({}, root)).toThrow( + /HTTP-exposed capabilities must declare "effect" as an inline/, + ); + }); + + it("rejects protocol-relative custom HTTP paths", () => { + const root = createFixture({ + capabilities: { + "notes-create.ts": CREATE_CAPABILITY.replace( + 'path: "/api/create-note"', + 'path: "//evil.test/collect"', + ), + }, + }); + + expect(() => extractCapabilities({}, root)).toThrow(/exact same-origin pathname/); + }); + + it("does not execute expressions while extracting projection metadata", () => { + const marker = `__prachtProjectionExecuted_${Date.now()}`; + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY.replace( + /input: \{[\s\S]*?\n \},/, + `input: (() => { globalThis.${marker} = true; return { type: "object" }; })(),`, + ), + }, + }); + + try { + expect(() => extractCapabilities({}, root)).toThrow(/inline object literal/); + expect((globalThis as Record)[marker]).toBeUndefined(); + } finally { + delete (globalThis as Record)[marker]; + } + }); +}); + +describe("createPrachtCapabilitiesClientModuleSource", () => { + it("types destructive confirmation metadata in browser envelopes", () => { + const error = { + code: "confirmation_required", + message: "Confirm the call.", + confirmationToken: "v1.payload.signature", + expiresAt: 1_800_000_000, + } satisfies CapabilityErrorPayload; + + expect(error.confirmationToken).toContain("v1."); + }); + + it("contains only http-exposed endpoints — no schemas, no server code", () => { + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY, + "notes-create.ts": CREATE_CAPABILITY, + "notes-private.ts": PRIVATE_CAPABILITY, + }, + }); + + const source = createPrachtCapabilitiesClientModuleSource({}, { root }); + expect(source).toContain( + '"notes.search":{"method":"POST","path":"/api/capabilities/notes/search","effect":"read"}', + ); + expect(source).toContain( + '"notes.create":{"method":"POST","path":"/api/create-note","effect":"write"}', + ); + expect(source).not.toContain("notes.private"); + expect(source).not.toContain("defineCapability"); + expect(source).toContain("export async function callCapability"); + }); + + it("emits an empty endpoint map for apps without capabilities", () => { + const root = createFixture({}); + const source = createPrachtCapabilitiesClientModuleSource({}, { root }); + expect(source).toContain("const endpoints = {};"); + }); + + it("forwards caller-supplied headers for confirmation flows", async () => { + const root = createFixture({ + capabilities: { + "notes-create.ts": CREATE_CAPABILITY, + }, + }); + const source = createPrachtCapabilitiesClientModuleSource({}, { root }); + let requestInit: RequestInit | undefined; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_input, init) => { + requestInit = init; + return new Response(JSON.stringify({ ok: true, data: { id: "n1" } }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${Date.now()}`; + const mod = (await import(moduleUrl)) as { + callCapability: ( + name: string, + input?: unknown, + opts?: { headers?: HeadersInit }, + ) => Promise; + }; + await mod.callCapability( + "notes.create", + { title: "Confirmed note" }, + { headers: { "x-pracht-confirm": "token-1" } }, + ); + } finally { + globalThis.fetch = originalFetch; + } + + const headers = requestInit?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("content-type")).toBe("application/json"); + expect((headers as Headers).get("x-pracht-confirm")).toBe("token-1"); + }); + + it("preserves explicit null input in browser calls", async () => { + const root = createFixture({ capabilities: { "notes-create.ts": CREATE_CAPABILITY } }); + const source = createPrachtCapabilitiesClientModuleSource({}, { root }); + let requestInit: RequestInit | undefined; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_input, init) => { + requestInit = init; + return Response.json({ ok: true, data: null }); + }) as typeof fetch; + + try { + const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${Date.now()}`; + const mod = (await import(moduleUrl)) as { + callCapability: (name: string, input?: unknown) => Promise; + }; + await mod.callCapability("notes.create", null); + } finally { + globalThis.fetch = originalFetch; + } + + expect(requestInit?.body).toBe("null"); + }); +}); + +describe("createPrachtWebmcpModuleSource", () => { + it("registers one tool per webmcp capability with its input schema", () => { + const root = createFixture({ + capabilities: { + "notes-search.ts": SEARCH_CAPABILITY, + "notes-create.ts": CREATE_CAPABILITY, + }, + }); + + const source = createPrachtWebmcpModuleSource({}, { root }); + expect(source).toContain('"name":"notes.search"'); + expect(source).toContain('"description":"Find notes whose title matches the query."'); + expect(source).toContain('"inputSchema":{"type":"object"'); + // notes.create is http-only — it must not become a page tool. + expect(source).not.toContain('"name":"notes.create"'); + // Targets the origin-trial API with the deprecated fallback. + expect(source).toContain("document.modelContext"); + expect(source).toContain("navigator.modelContext"); + expect(source).toContain("registerTool"); + }); +}); + +describe("client entry integration", () => { + it("imports the webmcp shim only when a capability opts in", () => { + const withWebmcp = createFixture({ capabilities: { "notes-search.ts": SEARCH_CAPABILITY } }); + const withoutWebmcp = createFixture({ capabilities: { "notes-create.ts": CREATE_CAPABILITY } }); + const none = createFixture({}); + + expect(createPrachtClientModuleSource({}, { root: withWebmcp })).toContain( + 'import("virtual:pracht/webmcp")', + ); + expect(createPrachtIslandsClientModuleSource({}, { root: withWebmcp })).toContain( + 'import("virtual:pracht/webmcp")', + ); + + for (const root of [withoutWebmcp, none]) { + expect(createPrachtClientModuleSource({}, { root })).not.toContain("webmcp"); + expect(createPrachtIslandsClientModuleSource({}, { root })).not.toContain("webmcp"); + } + }); +}); diff --git a/packages/vite-plugin/test/pages-router.test.ts b/packages/vite-plugin/test/pages-router.test.ts index 9048fb60..c8734fc0 100644 --- a/packages/vite-plugin/test/pages-router.test.ts +++ b/packages/vite-plugin/test/pages-router.test.ts @@ -201,6 +201,7 @@ describe("pracht plugin config", () => { "src/api/**/*.{ts,js,tsx,jsx}", "src/server/**/*.{ts,js,tsx,jsx}", "src/islands/**/*.{ts,tsx,js,jsx}", + "src/capabilities/**/*.{ts,js,tsx,jsx}", ]; expect(result.optimizeDeps?.entries).toEqual(["custom-entry.ts", ...expectedPrachtEntries]); diff --git a/packages/vite-plugin/test/plugin-options.test.ts b/packages/vite-plugin/test/plugin-options.test.ts index ba9c8880..6aea224c 100644 --- a/packages/vite-plugin/test/plugin-options.test.ts +++ b/packages/vite-plugin/test/plugin-options.test.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createPrachtServerModuleSource } from "../src/plugin-codegen.ts"; @@ -27,6 +28,65 @@ describe("resolveOptions budgets", () => { }); }); +describe("resolveOptions llmsTxt", () => { + it("defaults to disabled", () => { + expect(resolveOptions({}).llmsTxt).toBe(false); + }); + + it("accepts an options object", () => { + const resolved = resolveOptions({ + llmsTxt: { title: "My App", origin: "https://example.com", include: ["pages"] }, + }); + expect(resolved.llmsTxt).toEqual({ + title: "My App", + origin: "https://example.com", + include: ["pages"], + }); + }); + + it("rejects non-object values such as true", () => { + // @ts-expect-error — llmsTxt is `false | object`, not `true`. + expect(() => resolveOptions({ llmsTxt: true })).toThrow(/false or an options object/); + }); + + it("accepts the capabilities section", () => { + const resolved = resolveOptions({ llmsTxt: { include: ["capabilities"] } }); + expect(resolved.llmsTxt).toEqual({ include: ["capabilities"] }); + }); + + it("rejects unknown include sections", () => { + // @ts-expect-error — "sitemap" is not a valid section. + expect(() => resolveOptions({ llmsTxt: { include: ["sitemap"] } })).toThrow( + /"pages", "api", and\/or "capabilities"/, + ); + }); +}); + +describe("createPrachtServerModuleSource llmsTxt export", () => { + it("emits no llms.txt code when disabled", () => { + const source = createPrachtServerModuleSource(); + expect(source).not.toContain("generateLlmsTxt"); + expect(source).not.toContain("buildLlmsTxt"); + }); + + it("exports generateLlmsTxt with the configured options", () => { + const source = createPrachtServerModuleSource({ + llmsTxt: { title: "My App", description: "Demo.", origin: "https://example.com" }, + }); + expect(source).toContain('import { buildLlmsTxt } from "@pracht/core/server";'); + expect(source).toContain( + 'const llmsTxtConfig = {"title":"My App","description":"Demo.","origin":"https://example.com"};', + ); + expect(source).toContain("export const generateLlmsTxt = () =>"); + }); + + it("falls back to the app package.json name for the title", () => { + const packageRoot = fileURLToPath(new URL("..", import.meta.url)); + const source = createPrachtServerModuleSource({ llmsTxt: {} }, { root: packageRoot }); + expect(source).toContain('"title":"@pracht/vite-plugin"'); + }); +}); + describe("createPrachtServerModuleSource budgets export", () => { it("embeds the configured budgets in the server module", () => { const source = createPrachtServerModuleSource({ diff --git a/packages/vite-plugin/virtual.d.ts b/packages/vite-plugin/virtual.d.ts index 4f75f40d..0b7f0489 100644 --- a/packages/vite-plugin/virtual.d.ts +++ b/packages/vite-plugin/virtual.d.ts @@ -5,6 +5,67 @@ declare module "virtual:pracht/server" { declare module "virtual:pracht/client" {} +declare module "virtual:pracht/capabilities" { + import type { + CapabilityInputFor, + CapabilityOutputFor, + RegisteredCapabilityName, + } from "@pracht/core"; + import type { + CapabilityEffect, + CapabilityEnvelope, + CapabilityErrorPayload, + CapabilityIssue, + } from "@pracht/capabilities"; + + // The envelope types are the protocol package's — re-exported so existing + // `import type { ... } from "virtual:pracht/capabilities"` keeps working. + export type { CapabilityEnvelope, CapabilityErrorPayload, CapabilityIssue }; + + export interface CallCapabilityOptions { + headers?: HeadersInit; + signal?: AbortSignal; + /** + * Confirmation token for committing a destructive capability, taken from + * the prior call's `confirmation_required` error envelope. Sets the + * confirmation header for you. + */ + confirm?: string; + /** + * Successful non-`read` calls revalidate the active route's data + * automatically; pass `false` to skip it for this call. + */ + revalidate?: boolean; + } + + /** HTTP endpoints of http-exposed capabilities, keyed by capability name. */ + export const capabilityEndpoints: Record< + string, + { method: string; path: string; effect: CapabilityEffect | null } + >; + /** + * Invoke an http-exposed capability from the browser via its HTTP projection. + * When `pracht typegen` has registered the capability graph on + * `Register["capabilities"]`, input and output types are inferred from the + * capability name. + */ + export function callCapability( + name: TName, + input: CapabilityInputFor, + opts?: CallCapabilityOptions, + ): Promise>>; + export function callCapability( + name: string, + input?: unknown, + opts?: CallCapabilityOptions, + ): Promise>; +} + +declare module "virtual:pracht/webmcp" { + /** Registers WebMCP page tools; returns false when the API is unavailable. */ + export function registerPrachtWebmcpTools(): boolean; +} + // `.tsrx` modules are compiled by `@tsrx/vite-plugin-preact`. Declare an // ambient module so apps can `import` them without a typed source — TypeScript // has no built-in support for the `.tsrx` extension. diff --git a/playwright.config.ts b/playwright.config.ts index 60068ca5..92c97c5c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ }, { name: "pages-router", - testMatch: /pages-router\.test\.ts|dev-404\.test\.ts/, + testMatch: /pages-router\.test\.ts|dev-404\.test\.ts|llms-txt-dev\.test\.ts/, use: { baseURL: "http://localhost:3101", }, @@ -28,6 +28,13 @@ export default defineConfig({ baseURL: "http://localhost:3102", }, }, + { + name: "capabilities", + testMatch: /capabilities\.test\.ts/, + use: { + baseURL: "http://localhost:3103", + }, + }, ], webServer: [ { @@ -48,5 +55,16 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 15_000, }, + { + command: "node e2e/start-dev-server.mjs examples/basic 3103", + port: 3103, + reuseExistingServer: !process.env.CI, + timeout: 15_000, + env: { + // Enables the destructive-capability confirmation flow that + // e2e/capabilities.test.ts and the example eval scenario exercise. + PRACHT_CONFIRMATION_SECRET: "pracht-e2e-confirmation-secret", + }, + }, ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a573047..0e468c10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@pracht/adapter-vercel': specifier: workspace:* version: link:../../packages/adapter-vercel + '@pracht/capabilities': + specifier: workspace:* + version: link:../../packages/capabilities '@pracht/core': specifier: workspace:* version: link:../../packages/framework @@ -271,11 +274,16 @@ importers: specifier: workspace:* version: link:../framework + packages/capabilities: {} + packages/cli: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) + '@pracht/capabilities': + specifier: workspace:* + version: link:../capabilities '@pracht/core': specifier: workspace:* version: link:../framework @@ -288,6 +296,9 @@ importers: packages/framework: dependencies: + '@pracht/capabilities': + specifier: workspace:* + version: link:../capabilities '@standard-schema/spec': specifier: ^1.0.0 version: 1.1.0 @@ -326,6 +337,9 @@ importers: '@pracht/adapter-node': specifier: workspace:* version: link:../adapter-node + '@pracht/capabilities': + specifier: workspace:* + version: link:../capabilities '@pracht/core': specifier: workspace:* version: link:../framework diff --git a/skills/README.md b/skills/README.md index a114cee0..b1bba1d0 100644 --- a/skills/README.md +++ b/skills/README.md @@ -76,7 +76,8 @@ collide with other skill packs installed in the same app. pracht plugin; `inspect build`, the analyze report, `headers.json`, and the env-safety report all need a prior `pracht build`. - Use `pracht typegen` to refresh `src/pracht.d.ts` and - `src/pracht-routes.ts` after route ids or paths change; use + `src/pracht-routes.ts` after route ids or paths change — and + `src/pracht-capabilities.d.ts` after capability schemas change; use `pracht typegen --check` in verification/CI. Generating skills end their verification with `pracht verify --json`. - Audit skills produce a report; they never auto-fix. They report with a diff --git a/skills/typed-routes/SKILL.md b/skills/typed-routes/SKILL.md index c7b2c297..ec5dd396 100644 --- a/skills/typed-routes/SKILL.md +++ b/skills/typed-routes/SKILL.md @@ -60,6 +60,10 @@ This writes: - `src/pracht.d.ts` — module augmentation for route ids, params, loader data types, and API route request/response types (consumed by `apiFetch()`). - `src/pracht-routes.ts` — runtime `href()` helper backed by the same route map. +- `src/pracht-capabilities.d.ts` — only when the app registers capabilities: + input/output types from the capability schemas, so `invokeCapability()`, + `callCapability()`, and ``'s `onCapabilityResult` infer + types from the capability name. Earlier versions wrote the declaration to `src/pracht-routes.d.ts`; typegen removes that stale file automatically (TypeScript silently ignored it next to diff --git a/tsconfig.json b/tsconfig.json index 7e781188..8334e8ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "@pracht/core/env/server": ["./packages/framework/src/env-server.ts"], "@pracht/core/server": ["./packages/framework/src/server.ts"], "@pracht/core/islands-client": ["./packages/framework/src/islands-client.ts"], + "@pracht/capabilities": ["./packages/capabilities/src/index.ts"], "@pracht/vite-plugin": ["./packages/vite-plugin/src/index.ts"], "@pracht/preact-ssr-precompile": ["./packages/preact-ssr-precompile/src/index.ts"], "@pracht/adapter-node": ["./packages/adapter-node/src/index.ts"],