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.
- package/README.md +9 -7
- package/package.json +4 -1
- package/skills/add-auth/SKILL.md +173 -121
- package/skills/add-capabilities/SKILL.md +108 -96
- package/skills/add-observability/SKILL.md +12 -39
- package/skills/audit-agent-surface/SKILL.md +23 -17
- package/skills/audit-auth/SKILL.md +68 -12
- package/skills/audit-bundles/SKILL.md +11 -1
- package/skills/audit-shells/SKILL.md +12 -5
- package/skills/migrate-nextjs/SKILL.md +30 -10
- package/skills/pracht-debug/SKILL.md +17 -1
- package/skills/pracht-scaffold/SKILL.md +11 -1
- package/skills/pre-deploy/SKILL.md +15 -1
- package/skills/tune-render-mode/SKILL.md +36 -0
- package/skills/typed-routes/SKILL.md +3 -2
- package/skills/upgrade-pracht/SKILL.md +18 -8
- package/src/index.js +221 -59
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-capabilities
|
|
3
|
-
version: 1.0
|
|
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
|
|
31
|
-
inferred as
|
|
32
|
-
|
|
33
|
-
|
|
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`.
|
|
45
|
-
|
|
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
|
|
69
|
-
`
|
|
70
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
`
|
|
233
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 —
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
347
|
-
|
|
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
|
|
351
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
`
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
`
|
|
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
|
|
379
|
-
|
|
380
|
-
`
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
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
|
-
|
|
416
|
+
6. Design `write` inputs to be safely repeatable; agents retry, and only
|
|
405
417
|
`destructive` calls are token-gated.
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
205
|
-
import { useEffect } from "preact/hooks";
|
|
175
|
+
import { useWebVitals } from "@pracht/core";
|
|
206
176
|
|
|
207
177
|
export function Vitals() {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
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
|
|
63
|
-
|
|
64
|
-
`
|
|
65
|
-
|
|
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
|
|
281
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
- `create-pracht --no-agent-tools` controls
|
|
288
|
-
(`.mcp.json`, skills)
|
|
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.
|
|
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.
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`src/routes.ts` (or the
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 `
|
|
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
|
|
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:
|