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.
- package/README.md +7 -5
- package/package.json +4 -1
- package/skills/add-auth/SKILL.md +173 -121
- package/skills/add-capabilities/SKILL.md +107 -96
- package/skills/add-observability/SKILL.md +12 -39
- package/skills/audit-agent-surface/SKILL.md +21 -16
- package/skills/audit-auth/SKILL.md +68 -12
- package/skills/audit-bundles/SKILL.md +1 -1
- package/skills/audit-shells/SKILL.md +12 -5
- package/skills/migrate-nextjs/SKILL.md +30 -10
- package/skills/pracht-scaffold/SKILL.md +11 -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 +192 -61
|
@@ -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,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
|
|
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.
|
|
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
|
|
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).
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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 —
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
345
|
-
|
|
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
|
|
349
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
`
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
`
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
|
398
|
-
|
|
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
|
-
|
|
413
|
+
6. Design `write` inputs to be safely repeatable; agents retry, and only
|
|
403
414
|
`destructive` calls are token-gated.
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
281
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
- `create-pracht --no-agent-tools` controls
|
|
288
|
-
(`.mcp.json`, skills)
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-shells
|
|
3
|
-
version: 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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|