create-pracht 0.6.3 → 0.7.1

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,35 @@ 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
+
180
+ Unknown names, capabilities without `expose.webmcp`, and activation on
181
+ `hydration: "none"` routes are rejected. Initial hydration registers the matched
182
+ route's set; every committed client navigation replaces it, so never assume a
183
+ tool exposed on one page persists globally.
184
+
185
+ In dev, the tab's `pracht_page_tools` WebMCP tool lists the tools active on
186
+ the route and why a declared capability is not one.
187
+
158
188
  Each `agents` sub-option is independent — add only what the app uses. Web Bot
159
189
  Auth `policy: "require"` gates capability HTTP endpoints (not pages or API
160
190
  routes) with `401 agent_required`; `agentPolicy: "require"` on a capability
@@ -216,30 +246,14 @@ Rules to hold the user to:
216
246
  body that MCP dispatch reads next.
217
247
  - **The principal is `context.tokenAuth`** — a frozen `{ subject, scopes?,
218
248
  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.
249
+ and `run()` for authorization; the framework only authenticates. Nested calls
250
+ retain the transport-verified principal. Capture it separately when audit
251
+ events must identify the account.
229
252
  - `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).
253
+ query/fragment and exactly matching the deployed MCP path, including the app
254
+ base (`https://app.example.com/app/mcp`). Use HTTPS outside loopback.
255
+ `resolveApp()` and `pracht verify` reject malformed values and scope strings;
256
+ mismatched authenticated request URLs redirect to the canonical resource.
243
257
  - The bare `/.well-known/oauth-protected-resource` path is reserved for
244
258
  discovery and cannot be used as `mcp.path`. Production adapters route both
245
259
  metadata forms ahead of copied static files.
@@ -266,19 +280,18 @@ See `docs/REMOTE_MCP.md` for the metadata document and the full `verify` recipe.
266
280
 
267
281
  Be honest about what this buys, and say so to the user
268
282
  (`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.
283
+ the calling agent can hand the token back to itself, and without Web Bot Auth
284
+ or `setCapabilityApprovalPrincipalResolver()` both phases run as `"anonymous"`.
285
+ Register a `CapabilityApprovalStore` for exactly-once commits, and
286
+ `confirmation: { mode: "human" }` for a real human decision — that mode fails
287
+ closed without both a store and an authenticated principal.
274
288
 
275
289
  `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).
290
+ first-party durable store — one implementation for Postgres, Cloudflare D1, and
291
+ SQLite/Turso. Pass a parameterized-query function and run the migration from
292
+ `docs/AGENT_TRUST.md`; use `dialect: "postgres"` for `$1` placeholders.
293
+ `createMemoryApprovalStore()` is for tests only. A non-SQL backend needs atomic
294
+ conditional writes (Durable Objects, Redis — not Cloudflare KV).
282
295
 
283
296
  ### Destructive over remote MCP
284
297
 
@@ -286,18 +299,18 @@ Off by default. To serve one:
286
299
 
287
300
  1. `agents: { mcp: { destructive: true } }` in `defineApp()`.
288
301
  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.
302
+ exists before the graph is served — a token handed to the committing agent
303
+ must be consumable exactly once. The endpoint refuses to serve at all when
304
+ the store, `PRACHT_CONFIRMATION_SECRET`, or (in human mode) any resolvable
305
+ principal is missing; `pracht verify` warns when it cannot find the
306
+ registration in the configured source directories.
294
307
  3. The flow is unchanged; only the channel differs. Prepare answers
295
308
  `isError: true` with the token in `_meta["io.pracht/error"]`, and the commit
296
309
  repeats `tools/call` with identical `arguments` plus
297
310
  `_meta["io.pracht/confirmation"]`.
298
311
 
299
312
  Nested `invokeCapability()` under an MCP tool still refuses destructive callees
300
- unless the tool being served is a destructive capability that already cleared
313
+ unless the served tool is itself a destructive capability that already cleared
301
314
  prepare/commit.
302
315
 
303
316
  ## Step 6: Call it
@@ -343,49 +356,44 @@ pracht eval --start "pracht preview"
343
356
 
344
357
  Once the declaration exists the compiler rejects unknown names, bad input,
345
358
  browser calls to private capabilities, destructive calls without
346
- `prepare`/`confirm`, and runtime-computed names (assert
347
- `as HttpCapabilityName`). Re-run `pracht typegen --check` in CI.
359
+ `prepare`/`confirm`, and computed names (assert `as HttpCapabilityName`).
360
+ Re-run `pracht typegen --check` in CI.
348
361
 
349
362
  `pracht eval` runs JSON scenarios against the live app and exits 1 on a failed
350
- expectation — the repeatable answer to "can an agent actually finish this
351
- task?". Steps can reference earlier results
352
- (`$steps[0].error.confirmationToken`) and a scenario-level `signAs` block signs
363
+ expectation. Steps can reference earlier results
364
+ (`$steps[0].error.confirmationToken`); a scenario-level `signAs` block signs
353
365
  every step as a verified agent.
354
366
 
355
- A scenario targets the HTTP projection by default; set scenario-level
356
- `"transport": "mcp"` to run the same steps over the app's remote MCP endpoint
357
- (`initialize` handshake, then one `tools/call` per step, tool names mapped
358
- `notes.search` → `notes_search`). Write one of each for any capability with
359
- `expose.mcp` — passing over HTTP does not prove an MCP host can reach it.
367
+ A scenario targets HTTP by default; scenario-level `"transport": "mcp"` runs
368
+ the same steps over the remote MCP endpoint (`initialize`, then one
369
+ `tools/call` per step, names mapped `notes.search` → `notes_search`). Write one
370
+ of each for any `expose.mcp` capability — passing over HTTP does not prove an
371
+ MCP host can reach it.
360
372
  If `agents.mcp.auth` protects the endpoint, add scenario-level
361
- `"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize`
362
- and every later request. Inject test tokens in CI instead of committing real
363
- credentials. Step-level `headers.authorization` overrides it for one call.
364
- Expectations are portable: `expect.status` is the capability dispatch status on
365
- both transports, so the same `{ "ok": false, "status": 400, "errorCode":
366
- "invalid_input" }` holds either way.
367
-
368
- Three MCP limits fail loudly rather than silently: a step for a capability
369
- without `expose.mcp`, a step header other than `authorization` (the projection
370
- forwards nothing else), and a destructive step whose app has not enabled
371
- `agents.mcp.destructive` with an approval store. For an exposed destructive MCP
372
- tool, `confirm` completes the same prepare/commit round trip as HTTP; the token
373
- travels in the call's `_meta["io.pracht/confirmation"]` field.
373
+ `"mcpHeaders": { "authorization": "Bearer …" }`; it applies to `initialize` and
374
+ every later request, and step-level `headers.authorization` overrides it for
375
+ one call. Inject test tokens in CI. Expectations are portable: `expect.status`
376
+ is the capability dispatch status on both transports.
377
+
378
+ Three MCP limits fail loudly: a step for a capability without `expose.mcp`, a
379
+ step header other than `authorization` (the projection forwards nothing else),
380
+ and a destructive step whose app has not enabled `agents.mcp.destructive` with
381
+ an approval store. For an exposed destructive MCP tool, `confirm` completes the
382
+ same prepare/commit round trip as HTTP, with the token in the call's
383
+ `_meta["io.pracht/confirmation"]`.
374
384
 
375
385
  `createCapabilityTestHost()` from `@pracht/core` covers the same pipeline in
376
- unit tests without a server.
386
+ unit tests, without a server.
377
387
 
378
- WebMCP specifics `pracht verify` checks for you: tool names must fit the
379
- spec's grammar (1–128 ASCII `[a-zA-Z0-9_.-]`); an effective
380
- `agentPolicy: "require"` makes a page tool dead (unsigned browser fetches
381
- always 401 — warned); descriptions have advisory budgets (~500 chars per
382
- tool, ~150 per schema parameter). Hosts: the ChatGPT desktop browser enables
383
- the API itself, but stable Chrome/Edge visitors only get
384
- `document.modelContext` if the page head carries an origin-trial token — the
385
- capabilities page on the docs site shows the shell `head()` recipe.
388
+ WebMCP: names must fit the draft's 1–128 ASCII `[a-zA-Z0-9_.-]` grammar;
389
+ Chrome advises 30 chars/name, 500/tool description, 150/parameter description,
390
+ and 1.5K/result. `pracht verify` checks static limits; bound outputs yourself.
391
+ Stable Chrome needs an origin-trial token. No mainstream production agent
392
+ consumes these tools yet, so retain HTTP or remote MCP. See the capabilities
393
+ site page for current compatibility. An effective `agentPolicy: "require"`
394
+ always 401s unsigned page-tool calls and is warned.
386
395
 
387
- For an audit of what the whole agent surface currently exposes, run
388
- `/audit-agent-surface`.
396
+ To audit what the whole agent surface exposes, run `/audit-agent-surface`.
389
397
 
390
398
  ## Rules
391
399
 
@@ -396,16 +404,20 @@ For an audit of what the whole agent surface currently exposes, run
396
404
  2. Never widen a schema (drop `required`, open `additionalProperties`, raise a
397
405
  `maximum`) without saying so — `pracht plan` reports it as a widening of the
398
406
  agent-reachable surface for a reason.
399
- 3. Keep `expose`, `effect`, and `input` as inline literals.
400
- 4. Put authentication, authorization, and rate limiting in named middleware —
407
+ 3. Keep `expose` and `effect` inline; reuse a Standard JSON Schema validator for
408
+ `input`/`output` when the app already has one.
409
+ 4. Treat `expose.webmcp` as eligibility, not activation. Add the capability to
410
+ only the manifest routes/groups or Pages `CAPABILITIES` exports where an
411
+ in-page agent should see it.
412
+ 5. Put authentication, authorization, and rate limiting in named middleware —
401
413
  the framework ships no rate limiting, no write-idempotency helper, and no
402
414
  result-size budget. Bound outputs with a `limit` input and a schema
403
415
  `maximum`.
404
- 5. Design `write` inputs to be safely repeatable; agents retry, and only
416
+ 6. Design `write` inputs to be safely repeatable; agents retry, and only
405
417
  `destructive` calls are token-gated.
406
- 6. Never register an app-wide approval endpoint or UI without your own
418
+ 7. Never register an app-wide approval endpoint or UI without your own
407
419
  authorization — who may approve is an application decision.
408
- 7. Re-run `pracht typegen` after changing a schema, name, or exposure, and
420
+ 8. Re-run `pracht typegen` after changing a schema, name, exposure, or route activation, and
409
421
  `pracht verify` before committing.
410
422
 
411
423
  $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
 
@@ -82,7 +89,8 @@ For every exposed capability, ask whether the exposure is deliberate:
82
89
  the tool is dead — every call 401s. Also check that capabilities returning
83
90
  user-generated or third-party content set
84
91
  `expose.webmcp: { untrustedContent: true }` so hosts treat the output as
85
- untrusted.
92
+ untrusted. Flag `exposedTo` and outputs above 1.5K. Consequential work is
93
+ `destructive`, so never WebMCP.
86
94
  - Private capabilities used as building blocks: `invokeCapability()` runs their
87
95
  named middleware but **not** app-level `api.middleware`. Their named
88
96
  middleware is the only authorization seam — flag private capabilities with an
@@ -277,16 +285,16 @@ staleness, so trust it only when verify passes.
277
285
 
278
286
  When the app is supposed to have none:
279
287
 
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).
288
+ - Confirm no `capabilities` and no `agents` are registered (pages apps: no
289
+ `src/capabilities/`, no `_app.config.ts`). That lets the build define the
290
+ surface away (~15 KB gzip in an example server bundle).
282
291
  - Analysis is one-sided: a spread, a regex literal, or otherwise opaque syntax
283
292
  in the manifest leaves the define unset and keeps the runtime in the bundle.
284
293
  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.
294
+ - Confirm `llmsTxt` is off if the app should not advertise itself, and no route
295
+ sets `markdown: true`.
296
+ - `create-pracht --no-agent-tools` controls *scaffolded developer* tooling
297
+ (`.mcp.json`, skills), not the deployed agent surface. Do not conflate them.
290
298
 
291
299
  ## Step 8: Report
292
300
 
@@ -324,12 +332,10 @@ Severities:
324
332
  3. Do not treat client-declared signals as trust: the `webmcp` transport marker
325
333
  is informational, and only MCP dispatch state is trustworthy for
326
334
  attributing nested effects.
327
- 4. Distinguish `pracht mcp` (the development-time stdio server exposing the app
335
+ 4. Distinguish `pracht dev-mcp` (the development-time stdio server exposing the app
328
336
  *graph* to coding agents) from the deployed `/mcp` endpoint exposing the app's
329
337
  *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
338
+ 5. Pair with `/audit-auth`, `/audit-csrf`, and `/audit-secrets` — this skill
333
339
  owns agent reachability, not general request authorization.
334
340
 
335
341
  $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
 
@@ -181,6 +181,16 @@ pracht injects per route. Compare `<link rel="stylesheet">` tags in
181
181
  that wants Preact inside its own chunks or places `frameworkChunkGroups()`
182
182
  itself.
183
183
 
184
+ Pracht contributes one group to the **server** build too: each island becomes
185
+ its own chunk, so a route that does not fully hydrate can resolve its CSS from
186
+ that build without inheriting the server entry's merged stylesheet. An app that
187
+ configures SSR chunking composes with it the same way the client side does; a
188
+ group that pulls islands back into the entry shows up as extra `<link
189
+ rel="stylesheet">` tags on static pages, and the build warns.
190
+
191
+ `build.cssCodeSplit: false` is rejected outright — there is no `index.html` to
192
+ link the single stylesheet it produces, so every page would ship without one.
193
+
184
194
  ## Step 7: Report
185
195
 
186
196
  Three sections: