create-pracht 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: add-capabilities
3
- version: 1.0.2
3
+ version: 1.3.0
4
4
  description: |
5
5
  Expose an app operation as a typed pracht capability — one contract projected
6
- into direct server calls, an HTTP endpoint, a WebMCP page tool, and a remote MCP
6
+ into direct server calls, an HTTP endpoint, a route-scoped WebMCP page tool, and a remote MCP
7
7
  tool — plus `defineApp({ agents })` trust config, typed clients,
8
8
  `<Form capability>`, and `pracht eval` scenarios.
9
9
  Use for "add a capability", "expose this to agents", "add an MCP tool", "add
@@ -27,10 +27,11 @@ projection runs the identical pipeline, so rules never diverge per transport:
27
27
  input validation → named middleware chain → run() → output validation
28
28
  ```
29
29
 
30
- Registration is opt-in and private by default: no loader or API route is ever
31
- inferred as a capability, and an app that registers none ships no capability
32
- dispatch surface (the build drops ~15 KB gzip of dispatch and verifier code).
33
- Other agent-facing surfaces such as `llms.txt` remain independent.
30
+ Registration is opt-in and private by default; loaders and API routes are never
31
+ inferred as capabilities, and an app with none ships no dispatch surface.
32
+
33
+ Non-Pracht app: use `createCapabilityHost()` and the signal-owned WebMCP
34
+ registrar. See <https://pracht.resynapse.dev/docs/standalone-capabilities>.
34
35
 
35
36
  ## Step 1: Decide the contract before writing code
36
37
 
@@ -41,8 +42,11 @@ Settle these with `AskUserQuestion` when the request is vague:
41
42
  - **Effect** — `read`, `write`, or `destructive`. This drives confirmation
42
43
  gating, client revalidation, and MCP annotations. Classify honestly.
43
44
  - **Exposure** — private (omit `expose`), `http`, `webmcp` (requires `http`),
44
- `mcp`. Capabilities are manifest-router only; the pages router has no
45
- manifest to register them in.
45
+ `mcp`.
46
+ - **Router** — manifest apps use a `defineApp({ capabilities })` key; pages apps
47
+ auto-discover `src/capabilities/`, where each module declares
48
+ `name: "notes.search"` (or takes its file stem) and the name must map back to
49
+ its file with dots as hyphens (`notes-search.ts`). Step 4 covers the rest.
46
50
  - **Authorization** — which named middleware runs, and whether the endpoint
47
51
  requires a verified agent (`agentPolicy: "require"`).
48
52
 
@@ -65,11 +69,9 @@ pracht generate capability --name notes.search --effect read --expose http,webmc
65
69
  --description "Find notes whose title or body matches the query."
66
70
  ```
67
71
 
68
- The generator writes `src/capabilities/notes-search.ts` with `expose`,
69
- `effect`, and `input` as inline literals and registers the name in the
70
- manifest. `--description` is required whenever `--expose` is set — that text is
71
- the contract an agent reads. It refuses the combinations the runtime rejects
72
- anyway. The MCP `generate_capability` tool does the same thing.
72
+ The generator writes and registers `src/capabilities/notes-search.ts`.
73
+ `--description` is required with `--expose`; the MCP `generate_capability` tool
74
+ has the same contract.
73
75
 
74
76
  If dispatch answers `500 internal_error` and `pracht inspect capabilities`
75
77
  prints capabilities as `unreadable`, the package is missing — that is the
@@ -128,9 +130,10 @@ Schema rules that bite:
128
130
  could widen what an exposed capability accepts.
129
131
  - Inputs and outputs are JSON data only. `File`, `Blob`, `Date`, `Map`,
130
132
  `undefined`, and cycles are rejected — keep uploads in API routes.
131
- - `expose`, `effect`, and (for webmcp) `input` must be **inline literals**: the
132
- browser projection is built by static analysis, and an imported constant or
133
- spread fails the build.
133
+ - `expose` and `effect` must be **inline literals**. `input` and `output` may be
134
+ Standard Schema + Standard JSON Schema validators such as Zod 4. Pracht
135
+ derives the supported JSON subset server-side and runs the validator; async
136
+ validation, defaults, and transforms work without adding it to WebMCP.
134
137
  - MCP exposure additionally requires both schemas rooted at `type: "object"`.
135
138
  - Annotate `run()` with `CapabilityRunArgs<Input>` so TypeScript still infers
136
139
  the output; `defineCapability<Input>` alone leaves the output `unknown`.
@@ -139,6 +142,8 @@ Schema rules that bite:
139
142
 
140
143
  ```ts
141
144
  // src/routes.ts
145
+ import { defineApp, route } from "@pracht/core";
146
+
142
147
  export const app = defineApp({
143
148
  capabilities: {
144
149
  "notes.search": () => import("./capabilities/notes-search.ts"),
@@ -151,10 +156,31 @@ export const app = defineApp({
151
156
  // Remote MCP endpoint; without this, `expose.mcp` serves nothing.
152
157
  mcp: { serverInfo: { name: "notes", version: "1.0.0" }, instructions: "…" },
153
158
  },
154
- routes: [/* … */],
159
+ routes: [
160
+ route("/notes", "./routes/notes.tsx", {
161
+ // expose.webmcp makes it eligible; this makes it active on this page.
162
+ capabilities: ["notes.search"],
163
+ }),
164
+ ],
155
165
  });
156
166
  ```
157
167
 
168
+ Pages apps have no manifest: the same `capabilities` come from
169
+ `src/capabilities/` and the same `agents` object is `export const agents` in
170
+ `src/pages/_app.config.ts`. Activate WebMCP tools on each page that needs them:
171
+
172
+ ```ts
173
+ // src/pages/notes.tsx
174
+ export const CAPABILITIES = ["notes.search"];
175
+ ```
176
+
177
+ `CAPABILITIES` must be an inline array of non-empty registered names and cannot
178
+ appear on `_app` or `404`. In a manifest, group capability lists are additive.
179
+ Unknown names, capabilities without `expose.webmcp`, and activation on
180
+ `hydration: "none"` routes are rejected. Initial hydration registers the matched
181
+ route's set; every committed client navigation replaces it, so never assume a
182
+ tool exposed on one page persists globally.
183
+
158
184
  Each `agents` sub-option is independent — add only what the app uses. Web Bot
159
185
  Auth `policy: "require"` gates capability HTTP endpoints (not pages or API
160
186
  routes) with `401 agent_required`; `agentPolicy: "require"` on a capability
@@ -216,30 +242,14 @@ Rules to hold the user to:
216
242
  body that MCP dispatch reads next.
217
243
  - **The principal is `context.tokenAuth`** — a frozen `{ subject, scopes?,
218
244
  clientId?, claims? }`, alongside `context.agent`. Use it in named middleware
219
- and `run()` for per-user authorization; the framework only authenticates. It
220
- lives on a fresh request-local overlay, leaving an adapter's reused base
221
- context unchanged. Frozen and sealed ordinary contexts work; native built-ins
222
- such as `Map` and `Date` must be wrapped in an ordinary context. `claims` is
223
- frozen shallowly, but the complete principal is request-local so nested
224
- mutations cannot become stale auth on a later request. The capability audit
225
- event does not carry it yet, so capture it in named middleware or capability
226
- code and send it to the same audit sink if MCP calls must be attributable to
227
- an account. Nested capability calls rebind this field to the transport-verified
228
- principal, so caller-supplied composition context cannot replace it.
245
+ and `run()` for authorization; the framework only authenticates. Nested calls
246
+ retain the transport-verified principal. Capture it separately when audit
247
+ events must identify the account.
229
248
  - `resource` must be the endpoint's **real deployed URL**: absolute, free of
230
- query/fragment, free of a non-root trailing slash, and exactly matching the
231
- served endpoint's public path deploy base included, e.g.
232
- `https://app.example.com/app/mcp` for an app mounted at `/app/`.
233
- `resolveApp()` and `pracht verify` reject otherwise. The metadata document
234
- then lands at the origin root with the base inside the suffix
235
- (`/.well-known/oauth-protected-resource/app/mcp`); pracht derives it. Require
236
- HTTPS outside loopback development, and reject authorization-server issuers
237
- with query strings or fragments. For `mcp.path: "/"`, the resource is the
238
- deployed app root, including its base; at the origin root use slashless
239
- `https://app.example.com`. Authenticated requests whose URL is not
240
- exactly this identifier are redirected to it with `308` before token
241
- verification. Scope values must use OAuth's printable ASCII grammar (no
242
- spaces, controls, non-ASCII, quotes, or backslashes).
249
+ query/fragment and exactly matching the deployed MCP path, including the app
250
+ base (`https://app.example.com/app/mcp`). Use HTTPS outside loopback.
251
+ `resolveApp()` and `pracht verify` reject malformed values and scope strings;
252
+ mismatched authenticated request URLs redirect to the canonical resource.
243
253
  - The bare `/.well-known/oauth-protected-resource` path is reserved for
244
254
  discovery and cannot be used as `mcp.path`. Production adapters route both
245
255
  metadata forms ahead of copied static files.
@@ -266,19 +276,18 @@ See `docs/REMOTE_MCP.md` for the metadata document and the full `verify` recipe.
266
276
 
267
277
  Be honest about what this buys, and say so to the user
268
278
  (`docs/AGENT_TRUST.md`): stateless HMAC cannot prevent replay inside the TTL,
269
- the calling agent can hand the token straight back to itself, and without Web
270
- Bot Auth or `setCapabilityApprovalPrincipalResolver()` both phases run as
271
- `"anonymous"`. Register a `CapabilityApprovalStore` for exactly-once commits,
272
- and `confirmation: { mode: "human" }` for a real human decision — that mode
273
- fails closed without both a store and an authenticated principal.
279
+ the calling agent can hand the token back to itself, and without Web Bot Auth
280
+ or `setCapabilityApprovalPrincipalResolver()` both phases run as `"anonymous"`.
281
+ Register a `CapabilityApprovalStore` for exactly-once commits, and
282
+ `confirmation: { mode: "human" }` for a real human decision — that mode fails
283
+ closed without both a store and an authenticated principal.
274
284
 
275
285
  `createSqlApprovalStore({ execute })` from `@pracht/core/server` is the
276
- first-party durable store — no driver dependency, one implementation for
277
- Postgres, Cloudflare D1, and SQLite/Turso. Pass a parameterized-query function
278
- and run the migration from `docs/AGENT_TRUST.md`; use `dialect: "postgres"` for
279
- `$1` placeholders. `createMemoryApprovalStore()` is for tests and development
280
- only. A non-SQL backend needs atomic conditional writes (Durable Objects,
281
- Redis — not Cloudflare KV).
286
+ first-party durable store — one implementation for Postgres, Cloudflare D1, and
287
+ SQLite/Turso. Pass a parameterized-query function and run the migration from
288
+ `docs/AGENT_TRUST.md`; use `dialect: "postgres"` for `$1` placeholders.
289
+ `createMemoryApprovalStore()` is for tests only. A non-SQL backend needs atomic
290
+ conditional writes (Durable Objects, Redis — not Cloudflare KV).
282
291
 
283
292
  ### Destructive over remote MCP
284
293
 
@@ -286,18 +295,18 @@ Off by default. To serve one:
286
295
 
287
296
  1. `agents: { mcp: { destructive: true } }` in `defineApp()`.
288
297
  2. Register an approval store from a server entry or a capability module, so it
289
- exists before the graph is served. This is not optional — a token handed to
290
- the committing agent must be consumable exactly once. The endpoint refuses
291
- to serve at all when the store, `PRACHT_CONFIRMATION_SECRET`, or (in human
292
- mode) any resolvable principal is missing; `pracht verify` warns when it
293
- cannot find the registration in the configured source directories.
298
+ exists before the graph is served — a token handed to the committing agent
299
+ must be consumable exactly once. The endpoint refuses to serve at all when
300
+ the store, `PRACHT_CONFIRMATION_SECRET`, or (in human mode) any resolvable
301
+ principal is missing; `pracht verify` warns when it cannot find the
302
+ registration in the configured source directories.
294
303
  3. The flow is unchanged; only the channel differs. Prepare answers
295
304
  `isError: true` with the token in `_meta["io.pracht/error"]`, and the commit
296
305
  repeats `tools/call` with identical `arguments` plus
297
306
  `_meta["io.pracht/confirmation"]`.
298
307
 
299
308
  Nested `invokeCapability()` under an MCP tool still refuses destructive callees
300
- unless the tool being served is a destructive capability that already cleared
309
+ unless the served tool is itself a destructive capability that already cleared
301
310
  prepare/commit.
302
311
 
303
312
  ## Step 6: Call it
@@ -314,6 +323,8 @@ import { capabilities, useCapability } from "virtual:pracht/capabilities";
314
323
  const result = await capabilities.notes.search({ query: "roadmap" });
315
324
  ```
316
325
 
326
+ Unresolvable in TS? Add `"@pracht/vite-plugin/virtual"` to tsconfig `types`.
327
+
317
328
  ```tsx
318
329
  // One contract for the human form and the agent tool.
319
330
  <Form capability="notes.create" onCapabilityResult={(result) => { /* … */ }}>
@@ -341,49 +352,45 @@ pracht eval --start "pracht preview"
341
352
 
342
353
  Once the declaration exists the compiler rejects unknown names, bad input,
343
354
  browser calls to private capabilities, destructive calls without
344
- `prepare`/`confirm`, and runtime-computed names (assert
345
- `as HttpCapabilityName`). Re-run `pracht typegen --check` in CI.
355
+ `prepare`/`confirm`, and computed names (assert `as HttpCapabilityName`).
356
+ Re-run `pracht typegen --check` in CI.
346
357
 
347
358
  `pracht eval` runs JSON scenarios against the live app and exits 1 on a failed
348
- expectation the repeatable answer to "can an agent actually finish this
349
- task?". Steps can reference earlier results
350
- (`$steps[0].error.confirmationToken`) and a scenario-level `signAs` block signs
359
+ expectation. Steps can reference earlier results
360
+ (`$steps[0].error.confirmationToken`); a scenario-level `signAs` block signs
351
361
  every step as a verified agent.
352
362
 
353
- A scenario targets the HTTP projection by default; set scenario-level
354
- `"transport": "mcp"` to run the same steps over the app's remote MCP endpoint
355
- (`initialize` handshake, then one `tools/call` per step, tool names mapped
356
- `notes.search` `notes_search`). Write one of each for any capability with
357
- `expose.mcp` — passing over HTTP does not prove an MCP host can reach it.
363
+ A scenario targets HTTP by default; scenario-level `"transport": "mcp"` runs
364
+ the same steps over the remote MCP endpoint (`initialize`, then one
365
+ `tools/call` per step, names mapped `notes.search` → `notes_search`). Write one
366
+ of each for any `expose.mcp` capability passing over HTTP does not prove an
367
+ MCP host can reach it.
358
368
  If `agents.mcp.auth` protects the endpoint, add scenario-level
359
- `"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize`
360
- and every later request. Inject test tokens in CI instead of committing real
361
- credentials. Step-level `headers.authorization` overrides it for one call.
362
- Expectations are portable: `expect.status` is the capability dispatch status on
363
- both transports, so the same `{ "ok": false, "status": 400, "errorCode":
364
- "invalid_input" }` holds either way.
365
-
366
- Three MCP limits fail loudly rather than silently: a step for a capability
367
- without `expose.mcp`, a step header other than `authorization` (the projection
368
- forwards nothing else), and a destructive step whose app has not enabled
369
- `agents.mcp.destructive` with an approval store. For an exposed destructive MCP
370
- tool, `confirm` completes the same prepare/commit round trip as HTTP; the token
371
- travels in the call's `_meta["io.pracht/confirmation"]` field.
369
+ `"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize` and
370
+ every later request, and step-level `headers.authorization` overrides it for
371
+ one call. Inject test tokens in CI. Expectations are portable: `expect.status`
372
+ is the capability dispatch status on both transports.
373
+
374
+ Three MCP limits fail loudly: a step for a capability without `expose.mcp`, a
375
+ step header other than `authorization` (the projection forwards nothing else),
376
+ and a destructive step whose app has not enabled `agents.mcp.destructive` with
377
+ an approval store. For an exposed destructive MCP tool, `confirm` completes the
378
+ same prepare/commit round trip as HTTP, with the token in the call's
379
+ `_meta["io.pracht/confirmation"]`.
372
380
 
373
381
  `createCapabilityTestHost()` from `@pracht/core` covers the same pipeline in
374
- unit tests without a server.
382
+ unit tests, without a server.
375
383
 
376
- WebMCP specifics `pracht verify` checks for you: tool names must fit the
377
- spec's grammar (1–128 ASCII `[a-zA-Z0-9_.-]`); an effective
378
- `agentPolicy: "require"` makes a page tool dead (unsigned browser fetches
379
- always 401 — warned); descriptions have advisory budgets (~500 chars per
380
- tool, ~150 per schema parameter). Hosts: the ChatGPT desktop browser enables
381
- the API itself, but stable Chrome/Edge visitors only get
382
- `document.modelContext` if the page head carries an origin-trial token — the
383
- capabilities page on the docs site shows the shell `head()` recipe.
384
+ WebMCP specifics `pracht verify` checks: tool names must fit the spec's
385
+ grammar (1–128 ASCII `[a-zA-Z0-9_.-]`); an effective `agentPolicy: "require"`
386
+ makes a page tool dead (unsigned browser fetches always 401 — warned);
387
+ descriptions have advisory budgets (~500 chars per tool, ~150 per schema
388
+ parameter). Hosts: the ChatGPT desktop browser enables the API itself, but
389
+ stable Chrome/Edge visitors only get `document.modelContext` if the page head
390
+ carries an origin-trial token — the docs site's capabilities page shows the
391
+ shell `head()` recipe.
384
392
 
385
- For an audit of what the whole agent surface currently exposes, run
386
- `/audit-agent-surface`.
393
+ To audit what the whole agent surface exposes, run `/audit-agent-surface`.
387
394
 
388
395
  ## Rules
389
396
 
@@ -394,16 +401,20 @@ For an audit of what the whole agent surface currently exposes, run
394
401
  2. Never widen a schema (drop `required`, open `additionalProperties`, raise a
395
402
  `maximum`) without saying so — `pracht plan` reports it as a widening of the
396
403
  agent-reachable surface for a reason.
397
- 3. Keep `expose`, `effect`, and `input` as inline literals.
398
- 4. Put authentication, authorization, and rate limiting in named middleware —
404
+ 3. Keep `expose` and `effect` inline; reuse a Standard JSON Schema validator for
405
+ `input`/`output` when the app already has one.
406
+ 4. Treat `expose.webmcp` as eligibility, not activation. Add the capability to
407
+ only the manifest routes/groups or Pages `CAPABILITIES` exports where an
408
+ in-page agent should see it.
409
+ 5. Put authentication, authorization, and rate limiting in named middleware —
399
410
  the framework ships no rate limiting, no write-idempotency helper, and no
400
411
  result-size budget. Bound outputs with a `limit` input and a schema
401
412
  `maximum`.
402
- 5. Design `write` inputs to be safely repeatable; agents retry, and only
413
+ 6. Design `write` inputs to be safely repeatable; agents retry, and only
403
414
  `destructive` calls are token-gated.
404
- 6. Never register an app-wide approval endpoint or UI without your own
415
+ 7. Never register an app-wide approval endpoint or UI without your own
405
416
  authorization — who may approve is an application decision.
406
- 7. Re-run `pracht typegen` after changing a schema, name, or exposure, and
417
+ 8. Re-run `pracht typegen` after changing a schema, name, exposure, or route activation, and
407
418
  `pracht verify` before committing.
408
419
 
409
420
  $ARGUMENTS
@@ -169,53 +169,26 @@ slowest loaders (cross-reference with `audit-bundles` perf hotspots).
169
169
 
170
170
  ## Step 4: Web Vitals on the client
171
171
 
172
- ```bash
173
- pnpm add web-vitals
174
- ```
175
-
176
- Create `src/client/vitals.ts` — export a function, **no module-level
177
- side effects**:
178
-
179
- ```ts
180
- import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from "web-vitals";
181
-
182
- function send(metric: Metric) {
183
- navigator.sendBeacon?.(
184
- "/api/telemetry/vitals",
185
- JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, path: location.pathname }),
186
- );
187
- }
188
-
189
- export function reportVitals() {
190
- onCLS(send);
191
- onINP(send);
192
- onLCP(send);
193
- onFCP(send);
194
- onTTFB(send);
195
- }
196
- ```
197
-
198
- Do NOT import this statically from a shell: shells render on the **server**
199
- too, so module-level `onCLS(...)` calls would execute during SSR. The primary
200
- pattern is a lazy `import()` inside an effect, guarded by `useIsHydrated`
201
- (exported from `@pracht/core`), placed in a shell or top-level component:
172
+ Use the framework hook from a component mounted by a shared shell:
202
173
 
203
174
  ```tsx
204
- import { useIsHydrated } from "@pracht/core";
205
- import { useEffect } from "preact/hooks";
175
+ import { useWebVitals } from "@pracht/core";
206
176
 
207
177
  export function Vitals() {
208
- const hydrated = useIsHydrated();
209
- useEffect(() => {
210
- if (!hydrated) return;
211
- void import("../client/vitals").then((m) => m.reportVitals());
212
- }, [hydrated]);
178
+ useWebVitals((metric) => {
179
+ navigator.sendBeacon?.(
180
+ "/api/telemetry/vitals",
181
+ JSON.stringify({ name: metric.name, value: metric.value, id: metric.id, path: location.pathname }),
182
+ );
183
+ });
213
184
  return null;
214
185
  }
215
186
  ```
216
187
 
217
- This keeps `web-vitals` out of the critical bundle (lazy chunk) and only
218
- starts observers after hydration has fully settled.
188
+ The hook is safe during SSR, lazy-loads `web-vitals` after mount, and shares a
189
+ single observer set across callers. No separate dependency, hydration guard,
190
+ or client-only module is needed, and apps that never call it ship no metrics
191
+ runtime.
219
192
 
220
193
  ## Step 5: Beacon endpoint
221
194
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-agent-surface
3
- version: 1.0.4
3
+ version: 1.1.0
4
4
  description: |
5
5
  Inventory what agents can reach in a pracht app — capability exposure (HTTP,
6
6
  WebMCP, remote MCP), `agents` trust config, the destructive-confirmation gate,
@@ -59,10 +59,17 @@ middleware → `agentPolicy`. A capability reported as `unreadable` means
59
59
  `@pracht/capabilities` is not installed; treat it as an `error` and stop
60
60
  reasoning about its policy until it loads.
61
61
 
62
- Cross-check `inspect agents` against the manifest's `agents` block. It reads
63
- resolved app and production `llmsTxt` config, including computed branches. A
64
- `null` `llmsTxt.enabled` means an older plugin: report unknown and recommend an
65
- upgrade. Use resolved `mcp.auth`; `null` means framework-level OAuth is open.
62
+ Cross-check `inspect agents` against the app's `agents` config: the manifest's
63
+ `agents` block, or the `agents` export of `src/pages/_app.config.ts` when
64
+ `inspect` reports `"mode": "pages"`. It reads resolved app and production
65
+ `llmsTxt` config, including computed branches. `llmsTxt.enabled: null` means an older
66
+ plugin: report unknown and recommend upgrading. Use resolved
67
+ `mcp.auth`; `null` means framework OAuth is open.
68
+
69
+ Where capabilities are declared differs by router; what they expose does not.
70
+ A pages app registers every module in `src/capabilities/` — read that
71
+ directory, not a `capabilities` key. A pages app is not surface-free just
72
+ because it has no `src/routes.ts`.
66
73
 
67
74
  ## Step 2: Exposure vs. intent
68
75
 
@@ -277,16 +284,16 @@ staleness, so trust it only when verify passes.
277
284
 
278
285
  When the app is supposed to have none:
279
286
 
280
- - Confirm the manifest registers no `capabilities` and no `agents`. That lets
281
- the build define the surface away (~15 KB gzip of an example server bundle).
287
+ - Confirm no `capabilities` and no `agents` are registered (pages apps: no
288
+ `src/capabilities/`, no `_app.config.ts`). That lets the build define the
289
+ surface away (~15 KB gzip in an example server bundle).
282
290
  - Analysis is one-sided: a spread, a regex literal, or otherwise opaque syntax
283
291
  in the manifest leaves the define unset and keeps the runtime in the bundle.
284
292
  Flag manifest constructs that defeat the static read.
285
- - Confirm `llmsTxt` is off if the app should not advertise itself, and that no
286
- route sets `markdown: true`.
287
- - `create-pracht --no-agent-tools` controls the *scaffolded developer* tooling
288
- (`.mcp.json`, skills) it has nothing to do with the deployed agent surface.
289
- Do not conflate them in the report.
293
+ - Confirm `llmsTxt` is off if the app should not advertise itself, and no route
294
+ sets `markdown: true`.
295
+ - `create-pracht --no-agent-tools` controls *scaffolded developer* tooling
296
+ (`.mcp.json`, skills), not the deployed agent surface. Do not conflate them.
290
297
 
291
298
  ## Step 8: Report
292
299
 
@@ -324,12 +331,10 @@ Severities:
324
331
  3. Do not treat client-declared signals as trust: the `webmcp` transport marker
325
332
  is informational, and only MCP dispatch state is trustworthy for
326
333
  attributing nested effects.
327
- 4. Distinguish `pracht mcp` (the development-time stdio server exposing the app
334
+ 4. Distinguish `pracht dev-mcp` (the development-time stdio server exposing the app
328
335
  *graph* to coding agents) from the deployed `/mcp` endpoint exposing the app's
329
336
  *operations*. They have different threat models.
330
- 5. State the framework guarantee before each finding so the reader can tell an
331
- opt-out from a hole.
332
- 6. Pair with `/audit-auth`, `/audit-csrf`, and `/audit-secrets` — this skill
337
+ 5. Pair with `/audit-auth`, `/audit-csrf`, and `/audit-secrets` this skill
333
338
  owns agent reachability, not general request authorization.
334
339
 
335
340
  $ARGUMENTS
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-auth
3
- version: 1.2.4
3
+ version: 1.4.0
4
4
  description: |
5
5
  Find pracht routes that look protected but aren't: missing auth middleware,
6
6
  middleware that augments context but never gates, client-only checks, and
@@ -24,8 +24,18 @@ redirect `Response`. What the framework does NOT decide is *which* routes get
24
24
  an auth gate — that is app wiring, and this skill audits it.
25
25
 
26
26
  The pracht auth pattern (see `examples/docs/src/routes/docs/recipes-auth.md`):
27
- middleware checks the session, short-circuits with a redirect on absence, and
28
- forwards user info via request headers; loaders downstream read the headers.
27
+ middleware loads the session onto `context.session` and short-circuits with a
28
+ redirect when there is no user; loaders downstream read `context.session`.
29
+
30
+ `@pracht/session` is the first-party implementation. Two of its exports map
31
+ straight onto the Gate/Augmenter classification below — `requireSession()` is
32
+ a Gate, `sessionMiddleware()` is an Augmenter — so a project using it can be
33
+ classified from the factory name without reading the middleware body. A
34
+ project that hand-rolls its session instead is not automatically wrong, but
35
+ check it for the things the package handles: an expiry inside the signed
36
+ payload (not only `Max-Age`), a constant-time or `crypto.subtle.verify`
37
+ signature check, `HttpOnly`/`Secure`/`SameSite`, and encryption if the cookie
38
+ carries anything beyond an opaque id.
29
39
 
30
40
  Prerequisites: `pracht inspect` requires a vite config that registers the
31
41
  pracht plugin.
@@ -40,19 +50,38 @@ MCP: when the pracht MCP server is registered (docs/MCP.md), prefer its
40
50
  `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over
41
51
  shelling out.
42
52
 
43
- Middleware is registered by name in the app manifest
44
- `defineApp({ middleware: { auth: () => import("./middleware/auth.ts") } })`
45
- and `inspect` reports those names, not files. Read the name→file map from
46
- `src/routes.ts` (or the configured manifest) to resolve each name, then read
47
- each middleware file and classify it:
53
+ `inspect` reports middleware names, not files. Resolve them according to the
54
+ app's router mode, then read each middleware file and classify it:
55
+
56
+ - **Manifest router:** read the name→file map from `src/routes.ts` (or the
57
+ configured manifest), where middleware is registered through
58
+ `defineApp({ middleware: { auth: () => import("./middleware/auth.ts") } })`.
59
+ - **Pages router:** when the Vite config sets `pagesDir`, the generated name
60
+ `"pages"` resolves to the root `<pagesDir>/_middleware.ts` (or `.tsx`, `.js`,
61
+ or `.jsx`). It applies to every page route and never wraps API routes. There
62
+ is no `src/routes.ts` manifest to inspect unless the app has ejected. Follow
63
+ imports and re-exports into underscore-reserved helpers such as
64
+ `<pagesDir>/_server/auth.ts`; Pracht excludes those helpers from client
65
+ route/shell registries, but a direct import from client code still bundles
66
+ them and should be treated as a server-code leak.
67
+
68
+ Then classify each resolved middleware module:
48
69
 
49
70
  - **Gate** — on auth failure, returns a short-circuit `Response`
50
71
  (`redirect("/login", { request })`, or a 401/403 `Response`) WITHOUT calling
51
- `next()`; on success, `return next()`.
52
- - **Augmenter** — mutates request headers/context with user info, then always
53
- returns `next()`. Never short-circuits.
72
+ `next()`; on success, `return next()`. `requireSession()` from
73
+ `@pracht/session` is one.
74
+ - **Augmenter** — puts user info on `context` (or, in older code, on request
75
+ headers), then always returns `next()`. Never short-circuits.
76
+ `sessionMiddleware()` is one.
54
77
  - **Other** — non-auth middleware (rate limit, logging, CORS, etc.).
55
78
 
79
+ Flag any middleware that writes identity onto `args.request.headers`: the
80
+ client controls request headers, so a loader reading `x-user-id` back out is
81
+ trusting attacker-supplied input, and the write throws outright on Cloudflare
82
+ Workers where the incoming `Request` is immutable. Identity belongs on
83
+ `context`.
84
+
56
85
  The "Augmenter" category is the silent killer: it makes loaders *think*
57
86
  auth is enforced because `request.headers.get('x-user-id')` returns a value
58
87
  when present, but unauthenticated requests just get `null` and the loader has
@@ -63,7 +92,8 @@ to handle it. Flag every loader downstream of an Augmenter that doesn't.
63
92
  A route is "expected protected" if any of:
64
93
 
65
94
  - It has `auth`/`session`/`requireUser`/similar middleware applied.
66
- - Its loader reads `x-user-id`/`x-user-email`/`getSession`/equivalent.
95
+ - Its loader reads `context.session`, `getSession`, or (legacy)
96
+ `x-user-id`/`x-user-email`.
67
97
  - It lives under conventional protected paths: `/dashboard*`, `/admin*`,
68
98
  `/account*`, `/settings*`, `/app*` (ask the user to confirm the
69
99
  convention if unclear).
@@ -80,6 +110,13 @@ For each expected-protected route:
80
110
  3. Confirm the gate runs **before** any other middleware that depends on
81
111
  identity (order matters).
82
112
  4. If only an Augmenter is present, mark as `augmented-only`.
113
+ 5. For pages-router routes, inspect `render` too. A root pages Gate on an
114
+ `ssg` or `isg` route does not protect the static document per visitor:
115
+ document middleware runs during build/revalidation with a sanitized request,
116
+ while later route-state requests are separate live requests. Unless an
117
+ independently verified platform/CDN edge gate protects the document, report
118
+ the route as `error` / `unprotected-static` and recommend `ssr`/`spa` for
119
+ session-gated pages.
83
120
 
84
121
  ## Step 4: Check the API surface
85
122
 
@@ -138,6 +175,22 @@ SPA route loaders.
138
175
 
139
176
  ## Step 6: Session cookie sanity
140
177
 
178
+ With `@pracht/session`, check the configuration rather than the mechanics:
179
+ `cookie.secrets` read from `serverEnv` (never a literal), the storage built
180
+ inside a function rather than at module scope (Workers env is request-scoped),
181
+ a `__Host-` cookie name unless subdomain sharing is required, and `sameSite`
182
+ matching the app's embedding needs. A `store` is required for logout to
183
+ invalidate a session anywhere other than the browser that asked.
184
+
185
+ Then check the **login path for `session.regenerate()`**, called after
186
+ credentials verify and before the user is written onto the session. Its
187
+ absence is session fixation: with a store, an attacker who can plant a cookie
188
+ for the host keeps a valid pointer to the session that becomes authenticated.
189
+ Flag it as `error`/`fixation` on any store-backed app; on a cookie-only app it
190
+ is `info` (the cookie carries the sealed data, not a pointer), but still worth
191
+ adding before the app grows a store. Audit every other privilege change the
192
+ same way — 2FA completion, role assumption, impersonation.
193
+
141
194
  Cross-reference with `audit-csrf`: the same cookies that authorize the user
142
195
  are the CSRF target. Recommend running `audit-csrf` after this skill.
143
196
 
@@ -151,9 +204,12 @@ Severity is the primary scale; the verdict is a secondary domain label:
151
204
  - `error` / `unprotected` — no auth middleware on a route the user expects
152
205
  protected.
153
206
  - `error` / `inconsistent` — UI route is gated; sibling API is not.
207
+ - `error` / `unprotected-static` — a pages-router SSG/ISG document relies on
208
+ request/session middleware without an independent per-request edge gate.
154
209
  - `warn` / `augmented-only` — middleware reads session but never blocks;
155
210
  loader must handle null user.
156
211
  - `warn` / `client-only` — server allows; client hides UI.
212
+ - `error` / `fixation` — store-backed session, no `regenerate()` at login.
157
213
  - `info` / `protected` — gate confirmed.
158
214
  - `info` / `public-by-design` — deliberately exposed (login, signup,
159
215
  marketing).
@@ -108,7 +108,7 @@ For each route chunk over 50 KB gz, run `pracht inspect build --json` plus
108
108
  - Grep the chunk source for known heavy module headers (`moment`, `lodash`,
109
109
  `chart.js`, `three`, `@stripe/stripe-js`, etc.).
110
110
  - For each, recommend: (a) tree-shakeable alternative, (b) dynamic import
111
- inside an event handler, (c) lazy-load via `lazy()` from `preact-suspense`.
111
+ inside an event handler, (c) lazy-load via `lazy()` from `@pracht/core`.
112
112
 
113
113
  ## Step 6: Prefetch strategy
114
114
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-shells
3
- version: 1.1.1
3
+ version: 1.3.0
4
4
  description: |
5
5
  Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/
6
6
  `<body>` rendered inside a shell, shells that never render `children`, unused
@@ -103,10 +103,17 @@ JSON first:
103
103
  JSON has no shell registry of its own, so "unused" means "registered in
104
104
  `defineApp` but referenced by no route or group".
105
105
  - **Pages apps** (`mode: "pages"`): there is no `defineApp` shell registry.
106
- The shell is `src/pages/_app.tsx`, auto-registered under the name `"pages"`
107
- and applied to every route (see docs/ROUTING.md). "Unused shells" analysis
108
- does not apply; instead verify `_app.tsx` (if present) shows up as the
109
- resolved shell on every route.
106
+ Each `_app` file is auto-registered — `src/pages/_app.tsx` as `"pages"`,
107
+ `src/pages/blog/_app.tsx` as `"pages:blog"` and owns the routes in its
108
+ directory subtree (see docs/ROUTING.md). Shells **replace**, they do not
109
+ nest: the nearest `_app` above a route is the only one that renders it, so a
110
+ directory shell must carry its own `head()`/`headers()` rather than inherit
111
+ the parent's. "Unused shells" analysis does not apply; instead check
112
+ `pracht inspect routes --json` and confirm each route's `shell` is the
113
+ nearest `_app` you expect, and that no directory you meant to scope is
114
+ silently falling back to `"pages"`. An `_app` inside an underscore-reserved
115
+ tree (`src/pages/_components/_app.tsx`) is a plain helper, and two `_app`
116
+ files in one directory are a build, `doctor`, and `verify` error.
110
117
 
111
118
  Then report:
112
119