create-pracht 0.7.0 → 0.7.2
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 +2 -2
- package/fallback-versions.json +20 -0
- package/package.json +3 -2
- package/skills/add-auth/SKILL.md +25 -7
- package/skills/add-capabilities/SKILL.md +11 -8
- package/skills/add-db/SKILL.md +5 -3
- package/skills/add-images/SKILL.md +17 -3
- package/skills/add-observability/SKILL.md +13 -4
- package/skills/audit-agent-surface/SKILL.md +2 -1
- package/skills/audit-auth/SKILL.md +29 -1
- package/skills/audit-bundles/SKILL.md +25 -0
- package/skills/audit-headers/SKILL.md +4 -0
- package/skills/audit-islands/SKILL.md +41 -5
- package/skills/audit-loaders/SKILL.md +20 -3
- package/skills/audit-redirects/SKILL.md +22 -8
- package/skills/audit-secrets/SKILL.md +15 -4
- package/skills/audit-shells/SKILL.md +17 -1
- package/skills/migrate-nextjs/SKILL.md +5 -5
- package/skills/pracht-debug/SKILL.md +20 -2
- package/skills/pracht-deploy/SKILL.md +13 -2
- package/skills/pracht-scaffold/SKILL.md +6 -4
- package/skills/pre-deploy/SKILL.md +23 -1
- package/skills/tune-render-mode/SKILL.md +20 -2
- package/skills/typed-routes/SKILL.md +50 -3
- package/skills/upgrade-pracht/SKILL.md +7 -2
- package/src/index.js +99 -25
- package/src/versions.js +43 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npm run dev
|
|
|
17
17
|
- Detects the active package manager from the current environment.
|
|
18
18
|
- Lets the user choose between the Node.js, Cloudflare, Vercel, Netlify, and static adapters.
|
|
19
19
|
- Optionally wires up Tailwind CSS (`tailwindcss` + `@tailwindcss/vite`, a global stylesheet, and the shell import).
|
|
20
|
-
- Scaffolds a minimal app with a route manifest or pages router, shell, home route, not-found page, a sample API route for serverful adapters, runnable project README, TypeScript typecheck script, and (with agent tooling enabled) agent instructions.
|
|
20
|
+
- Scaffolds a minimal app with a route manifest or pages router, shell, home route, not-found page, a sample API route for serverful adapters, runnable project README, TypeScript typecheck script, browser-aware package resolution that rejects server-only root exports in client code, and (with agent tooling enabled) agent instructions.
|
|
21
21
|
- Manifest scaffolds include a commented-out `constraints` example in `src/routes.ts`, ready for `pracht verify`.
|
|
22
22
|
- The generated `.gitignore` keeps `.pracht/app-graph.json` committable, and the README and agent instructions cover the `pracht verify` / `pracht plan` / `pracht report` loop.
|
|
23
23
|
- Every standalone pnpm scaffold includes a narrow lifecycle-script policy for
|
|
@@ -100,7 +100,7 @@ writing it would leave a config in the repo that nothing in the repo reads.
|
|
|
100
100
|
|
|
101
101
|
- `dev` -> `pracht dev`
|
|
102
102
|
- `build` -> `pracht build`
|
|
103
|
-
- `typecheck` ->
|
|
103
|
+
- `typecheck` -> checks the server-capable base TypeScript program, then the browser-conditioned routes, shells, and islands in `tsconfig.client.json`
|
|
104
104
|
|
|
105
105
|
Node starters also include:
|
|
106
106
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"@pracht/adapter-cloudflare": "^0.8.0",
|
|
3
|
+
"@pracht/adapter-netlify": "^0.3.0",
|
|
4
|
+
"@pracht/adapter-node": "^0.5.0",
|
|
5
|
+
"@pracht/adapter-static": "^0.2.0",
|
|
6
|
+
"@pracht/adapter-vercel": "^0.4.0",
|
|
7
|
+
"@pracht/capabilities": "^0.6.0",
|
|
8
|
+
"@pracht/cli": "^1.15.0",
|
|
9
|
+
"@pracht/content": "^0.1.4",
|
|
10
|
+
"@pracht/core": "^0.19.0",
|
|
11
|
+
"@pracht/i18n": "^0.1.5",
|
|
12
|
+
"@pracht/image": "^0.5.0",
|
|
13
|
+
"@pracht/markdown": "^0.1.4",
|
|
14
|
+
"@pracht/openapi": "^0.2.6",
|
|
15
|
+
"@pracht/preact-ssr-precompile": "^0.1.4",
|
|
16
|
+
"@pracht/query": "^0.1.0",
|
|
17
|
+
"@pracht/session": "^0.1.2",
|
|
18
|
+
"@pracht/test": "^0.3.0",
|
|
19
|
+
"@pracht/vite-plugin": "^0.14.0"
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pracht",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pracht",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"bin",
|
|
31
31
|
"src",
|
|
32
32
|
"skills",
|
|
33
|
+
"fallback-versions.json",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
35
36
|
"type": "module",
|
|
@@ -40,6 +41,6 @@
|
|
|
40
41
|
"provenance": true
|
|
41
42
|
},
|
|
42
43
|
"scripts": {
|
|
43
|
-
"build": "node ./scripts/sync-skills.js"
|
|
44
|
+
"build": "node ./scripts/sync-skills.js && node ./scripts/sync-versions.js"
|
|
44
45
|
}
|
|
45
46
|
}
|
package/skills/add-auth/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-auth
|
|
3
|
-
version: 2.1.
|
|
3
|
+
version: 2.1.1
|
|
4
4
|
description: |
|
|
5
5
|
Wire session-based auth into a pracht app with `@pracht/session`: encrypted
|
|
6
6
|
cookie sessions, gate middleware, login/logout/signup API routes, `<Form>`
|
|
@@ -170,23 +170,40 @@ Workers, measure a login against the plan's CPU limit.
|
|
|
170
170
|
|
|
171
171
|
## Step 5: Auth API routes
|
|
172
172
|
|
|
173
|
+
`src/server/redirects.ts` — the one gate for every request-derived redirect
|
|
174
|
+
target. Never use a `startsWith("/") && !startsWith("//")` prefix check: URL
|
|
175
|
+
parsing turns `\` into `/`, so `/\evil.com` passes it and lands off-origin.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
export function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
|
|
179
|
+
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
|
|
180
|
+
try {
|
|
181
|
+
const target = new URL(value, base);
|
|
182
|
+
return target.origin === base.origin
|
|
183
|
+
? `${target.pathname}${target.search}${target.hash}`
|
|
184
|
+
: fallback;
|
|
185
|
+
} catch {
|
|
186
|
+
return fallback;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
173
191
|
`src/api/auth/login.ts`:
|
|
174
192
|
|
|
175
193
|
```ts
|
|
176
194
|
import { redirect, type ApiRouteArgs } from "@pracht/core";
|
|
177
195
|
|
|
196
|
+
import { safeRedirectPath } from "../../server/redirects.ts";
|
|
178
197
|
import { sessions } from "../../server/session.ts";
|
|
179
198
|
import { verifyCredentials } from "../../server/users.ts";
|
|
180
199
|
|
|
181
|
-
export async function POST({ request }: ApiRouteArgs) {
|
|
200
|
+
export async function POST({ request, url }: ApiRouteArgs) {
|
|
182
201
|
const form = await request.formData();
|
|
183
202
|
const email = String(form.get("email") ?? "");
|
|
184
203
|
const password = String(form.get("password") ?? "");
|
|
185
204
|
|
|
186
205
|
// The redirect field is user input — gate it, or this is an open redirect.
|
|
187
|
-
const
|
|
188
|
-
const target =
|
|
189
|
-
requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard";
|
|
206
|
+
const target = safeRedirectPath(form.get("redirect"), url, "/dashboard");
|
|
190
207
|
|
|
191
208
|
const user = await verifyCredentials(email, password);
|
|
192
209
|
if (!user) {
|
|
@@ -226,11 +243,12 @@ export async function POST({ request }: ApiRouteArgs) {
|
|
|
226
243
|
```tsx
|
|
227
244
|
import { Form, type LoaderArgs, type RouteComponentProps } from "@pracht/core";
|
|
228
245
|
|
|
246
|
+
import { safeRedirectPath } from "../server/redirects.ts";
|
|
247
|
+
|
|
229
248
|
export async function loader({ url }: LoaderArgs) {
|
|
230
|
-
const requested = url.searchParams.get("redirect") ?? "/dashboard";
|
|
231
249
|
return {
|
|
232
250
|
error: url.searchParams.get("error") === "1",
|
|
233
|
-
redirect:
|
|
251
|
+
redirect: safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"),
|
|
234
252
|
};
|
|
235
253
|
}
|
|
236
254
|
|
|
@@ -176,11 +176,15 @@ export const CAPABILITIES = ["notes.search"];
|
|
|
176
176
|
|
|
177
177
|
`CAPABILITIES` must be an inline array of non-empty registered names and cannot
|
|
178
178
|
appear on `_app` or `404`. In a manifest, group capability lists are additive.
|
|
179
|
+
|
|
179
180
|
Unknown names, capabilities without `expose.webmcp`, and activation on
|
|
180
181
|
`hydration: "none"` routes are rejected. Initial hydration registers the matched
|
|
181
182
|
route's set; every committed client navigation replaces it, so never assume a
|
|
182
183
|
tool exposed on one page persists globally.
|
|
183
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
|
+
|
|
184
188
|
Each `agents` sub-option is independent — add only what the app uses. Web Bot
|
|
185
189
|
Auth `policy: "require"` gates capability HTTP endpoints (not pages or API
|
|
186
190
|
routes) with `401 agent_required`; `agentPolicy: "require"` on a capability
|
|
@@ -381,14 +385,13 @@ same prepare/commit round trip as HTTP, with the token in the call's
|
|
|
381
385
|
`createCapabilityTestHost()` from `@pracht/core` covers the same pipeline in
|
|
382
386
|
unit tests, without a server.
|
|
383
387
|
|
|
384
|
-
WebMCP
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
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.
|
|
392
395
|
|
|
393
396
|
To audit what the whole agent surface exposes, run `/audit-agent-surface`.
|
|
394
397
|
|
package/skills/add-db/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-db
|
|
3
|
-
version: 1.2.
|
|
3
|
+
version: 1.2.1
|
|
4
4
|
description: |
|
|
5
5
|
Wire Drizzle ORM into a pracht app: pick the target (D1, PlanetScale, Neon,
|
|
6
6
|
Supabase, Turso, Postgres, MySQL, SQLite), then generate driver setup, schema,
|
|
@@ -93,11 +93,13 @@ export const db = drizzle(pool, { schema });
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
For Cloudflare D1, register the Cloudflare context type once via the `Register`
|
|
96
|
-
augmentation
|
|
97
|
-
`
|
|
96
|
+
augmentation. Keep the `import`: without it the file is a global script and the
|
|
97
|
+
declaration replaces `@pracht/core`'s types instead of extending them.
|
|
98
98
|
|
|
99
99
|
```ts
|
|
100
100
|
// src/env.d.ts
|
|
101
|
+
import "@pracht/core";
|
|
102
|
+
|
|
101
103
|
declare module "@pracht/core" {
|
|
102
104
|
interface Register {
|
|
103
105
|
context: {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-images
|
|
3
|
-
version: 1.0
|
|
3
|
+
version: 1.1.0
|
|
4
4
|
description: |
|
|
5
5
|
Wire `@pracht/image`: the CLS-safe zero-runtime `<Image>`, the loader for your
|
|
6
|
-
target (sharp endpoint, Cloudflare, Vercel, passthrough), `?pracht` build-time
|
|
6
|
+
target (sharp endpoint, Cloudflare, Vercel, Netlify, passthrough), `?pracht` build-time
|
|
7
7
|
imports with blur placeholders, prebuilt WebP variants, and the optimization
|
|
8
8
|
endpoint's security settings.
|
|
9
9
|
Use for "add images", "optimize images", "responsive images", "next/image
|
|
@@ -39,6 +39,7 @@ pracht inspect build --json # adapterTarget (requires a prior `pracht build`)
|
|
|
39
39
|
| Node | `defaultLoader` + the built-in endpoint | needs `sharp`; put a CDN in front |
|
|
40
40
|
| Cloudflare | `cloudflareLoader` | Image Resizing must be enabled on the zone; sharp does not run on Workers |
|
|
41
41
|
| Vercel | `vercelLoader` | needs an `images` section in the project config; Vercel only serves widths listed in `images.sizes` |
|
|
42
|
+
| Netlify | `netlifyLoader` | Image CDN; remote sources need `images.remote_images` in `netlify.toml`; no sharp endpoint |
|
|
42
43
|
| Static host | `passthroughLoader` | no image service — srcset is omitted |
|
|
43
44
|
| Any target, no runtime service | `?pracht&pracht-static` imports | prebuilt WebP variants, no loader involved |
|
|
44
45
|
|
|
@@ -76,9 +77,22 @@ configureImage({
|
|
|
76
77
|
|
|
77
78
|
`createDefaultLoader("/my/endpoint")` builds a default-style loader for a custom
|
|
78
79
|
endpoint path. Root-absolute endpoints pick up Vite's deploy `base`
|
|
79
|
-
automatically; provider loaders (`cloudflareLoader`, `vercelLoader`)
|
|
80
|
+
automatically; provider loaders (`cloudflareLoader`, `vercelLoader`, `netlifyLoader`)
|
|
80
81
|
deliberately stay at the origin root.
|
|
81
82
|
|
|
83
|
+
For Netlify, use `configureImage({ loader: import.meta.env.DEV ?
|
|
84
|
+
passthroughLoader : netlifyLoader })` so `pracht dev` renders original images.
|
|
85
|
+
Import both loaders from `@pracht/image`. For remote sources, merge the
|
|
86
|
+
allowed URL patterns into the existing `netlify.toml`:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
[images]
|
|
90
|
+
remote_images = ['https://images\.example\.com/.*']
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Local source files need no image-service configuration; Netlify negotiates
|
|
94
|
+
the output format and caches resized images. Skip the Node endpoint step.
|
|
95
|
+
|
|
82
96
|
## Step 4: Render images
|
|
83
97
|
|
|
84
98
|
```tsx
|
|
@@ -48,6 +48,12 @@ but trivial.
|
|
|
48
48
|
|
|
49
49
|
## Step 2: Server error tracking
|
|
50
50
|
|
|
51
|
+
Pracht already prints one line per 5xx to the server console — phase, route id,
|
|
52
|
+
source file, request path, message, and the stack — so the app is not silent
|
|
53
|
+
before any of this is wired. What that default does not give you is a request
|
|
54
|
+
id, a duration, the successful requests, or delivery to a sink off the box.
|
|
55
|
+
Say so when the user only wants "errors in my logs": they may already have it.
|
|
56
|
+
|
|
51
57
|
### Sentry path (Node adapter)
|
|
52
58
|
|
|
53
59
|
The pattern below uses `@sentry/node` and works on the **Node adapter only**
|
|
@@ -274,10 +280,13 @@ Key properties to state when scaffolding this:
|
|
|
274
280
|
- Delivery snapshots the registered sinks before callbacks run. Adding or
|
|
275
281
|
replacing a sink from inside a callback takes effect on the next dispatch,
|
|
276
282
|
so the current event is never delivered twice to one name.
|
|
277
|
-
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
283
|
+
- A sink's returned promise is handed to the request's `waitUntil()`, so an
|
|
284
|
+
`async` sink finishes after the response on every adapter (Cloudflare
|
|
285
|
+
`ctx.waitUntil`, Netlify/Vercel `context.waitUntil`, Node's shutdown drain)
|
|
286
|
+
and its rejection is reported. A batching exporter that flushes on its own
|
|
287
|
+
schedule should be flushed from middleware with
|
|
288
|
+
`args.waitUntil(exporter.flush())` — never reach for
|
|
289
|
+
`context.executionContext.waitUntil`.
|
|
281
290
|
- Audit events cover *dispatch* only. A cross-origin 403, an unknown-capability
|
|
282
291
|
404, and an unknown MCP tool name all return before dispatch and emit
|
|
283
292
|
nothing, so do not build a reconnaissance alert on these events — use the
|
|
@@ -89,7 +89,8 @@ For every exposed capability, ask whether the exposure is deliberate:
|
|
|
89
89
|
the tool is dead — every call 401s. Also check that capabilities returning
|
|
90
90
|
user-generated or third-party content set
|
|
91
91
|
`expose.webmcp: { untrustedContent: true }` so hosts treat the output as
|
|
92
|
-
untrusted.
|
|
92
|
+
untrusted. Flag `exposedTo` and outputs above 1.5K. Consequential work is
|
|
93
|
+
`destructive`, so never WebMCP.
|
|
93
94
|
- Private capabilities used as building blocks: `invokeCapability()` runs their
|
|
94
95
|
named middleware but **not** app-level `api.middleware`. Their named
|
|
95
96
|
middleware is the only authorization seam — flag private capabilities with an
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-auth
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.5.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
|
|
@@ -87,6 +87,13 @@ auth is enforced because `request.headers.get('x-user-id')` returns a value
|
|
|
87
87
|
when present, but unauthenticated requests just get `null` and the loader has
|
|
88
88
|
to handle it. Flag every loader downstream of an Augmenter that doesn't.
|
|
89
89
|
|
|
90
|
+
A shell `loader` that redirects anonymous users is **not** a gate either. A
|
|
91
|
+
client that already holds the shell's data sends `x-pracht-shell-data: <shell>`
|
|
92
|
+
on route-state requests and the server skips the shell loader, so the route's
|
|
93
|
+
data comes back without it running. Flag any shell whose loader is the only
|
|
94
|
+
thing between an anonymous request and the routes under it; the gate belongs
|
|
95
|
+
in middleware.
|
|
96
|
+
|
|
90
97
|
## Step 2: Identify protected routes
|
|
91
98
|
|
|
92
99
|
A route is "expected protected" if any of:
|
|
@@ -165,6 +172,27 @@ target. From `pracht inspect api --json`:
|
|
|
165
172
|
- Common bug: dashboard route is protected by middleware, but
|
|
166
173
|
`POST /api/items` is not — attacker bypasses the UI entirely.
|
|
167
174
|
|
|
175
|
+
### Server islands
|
|
176
|
+
|
|
177
|
+
Every module in `src/server-islands/` (or `pracht({ serverIslandsDir })`) is
|
|
178
|
+
reachable at `GET /__pracht/server-island` with the visitor's cookies, for any
|
|
179
|
+
page whose route or shell lists it in `export const serverIslands = [...]` —
|
|
180
|
+
with that route's middleware
|
|
181
|
+
(see <https://pracht.resynapse.dev/docs/server-islands#security>). Map each
|
|
182
|
+
server island to the routes that list it, directly or through their shell.
|
|
183
|
+
|
|
184
|
+
- Flag a server island listed by both gated and ungated routes whose loader
|
|
185
|
+
returns user-specific data without checking `context` itself — `error`: the
|
|
186
|
+
ungated route serves it to anyone.
|
|
187
|
+
- Flag pages that gate a server island only at render time
|
|
188
|
+
(`{isAdmin && <AdminStats />}` behind a loader check, not middleware) —
|
|
189
|
+
`error`: the endpoint runs it for anyone who passes that route's middleware.
|
|
190
|
+
- Flag server island loaders that pick *whose* data to load from `props`
|
|
191
|
+
(`userId`, `orderId`, `accountId`) — the caller controls the query string
|
|
192
|
+
(`error`, IDOR). Identity must come from `context`.
|
|
193
|
+
- Flag server island loaders with side effects (writes, sends) — the endpoint is
|
|
194
|
+
a `GET` (`warn`).
|
|
195
|
+
|
|
168
196
|
## Step 5: Client/server enforcement parity
|
|
169
197
|
|
|
170
198
|
Grep client components for patterns like `if (!user) return <Login />`. For
|
|
@@ -56,6 +56,14 @@ reference with `dist/client/.vite/manifest.json` for chunk metadata
|
|
|
56
56
|
(file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in
|
|
57
57
|
analysis (Step 4) and CSS sizing, which the analyze report does not cover.
|
|
58
58
|
|
|
59
|
+
Size CSS from the `cssManifest` this command reports, never from
|
|
60
|
+
`dist/client/.vite/manifest.json`. A `hydration: "none"` or `"islands"` route is
|
|
61
|
+
absent from the client manifest by construction, so reading it directly reports
|
|
62
|
+
no CSS for a page whose document links a stylesheet — under-reporting exactly
|
|
63
|
+
the routes pracht recommends. `inspect` merges `dist/server/css-manifest.json`,
|
|
64
|
+
the build's own record, over the client manifest; an older build without that
|
|
65
|
+
file is client-only, and the CSS column should say so rather than read as zero.
|
|
66
|
+
|
|
59
67
|
## Step 3: Interpret per-route payload
|
|
60
68
|
|
|
61
69
|
From the Step 1 JSON, report:
|
|
@@ -181,6 +189,23 @@ pracht injects per route. Compare `<link rel="stylesheet">` tags in
|
|
|
181
189
|
that wants Preact inside its own chunks or places `frameworkChunkGroups()`
|
|
182
190
|
itself.
|
|
183
191
|
|
|
192
|
+
Pracht contributes one group to the **server** build too: each island becomes
|
|
193
|
+
its own chunk, so a route that does not fully hydrate can resolve its CSS from
|
|
194
|
+
that build without inheriting the server entry's merged stylesheet. An app that
|
|
195
|
+
configures SSR chunking composes with it the same way the client side does; a
|
|
196
|
+
group that pulls islands back into the entry shows up as extra `<link
|
|
197
|
+
rel="stylesheet">` tags on static pages, and the build warns.
|
|
198
|
+
|
|
199
|
+
`build.cssCodeSplit: false` is rejected outright — there is no `index.html` to
|
|
200
|
+
link the single stylesheet it produces, so every page would ship without one.
|
|
201
|
+
|
|
202
|
+
When a route's CSS is small and the site is entered cold — a static export, a
|
|
203
|
+
content site arriving from search — the stylesheet link is often the only
|
|
204
|
+
render-blocking request left on a page that ships no JavaScript. Report
|
|
205
|
+
`pracht({ inlineCss: true })` as a suggestion there, with the cost: the same
|
|
206
|
+
bytes in every document and no shared stylesheet cache. An app whose visitors
|
|
207
|
+
move between pages should keep the link.
|
|
208
|
+
|
|
184
209
|
## Step 7: Report
|
|
185
210
|
|
|
186
211
|
Three sections:
|
|
@@ -121,6 +121,10 @@ into `speculation` (they emit an inline
|
|
|
121
121
|
`<script id="pracht-state" type="application/json">` — it does not need
|
|
122
122
|
`'unsafe-inline'`. Do not add `'unsafe-inline'` unless the app truly emits
|
|
123
123
|
executable inline scripts and the tradeoff is documented.
|
|
124
|
+
5. Routes with `streaming: true` emit executable inline scripts for deferred
|
|
125
|
+
data and boundary swaps. Under a nonce policy, return the request nonce as
|
|
126
|
+
`scriptNonce` from the shell `head()` and allow it in `script-src`; it also
|
|
127
|
+
covers the speculation rules script.
|
|
124
128
|
|
|
125
129
|
Output a draft CSP referencing `docs/CSP.md` and explain what each origin is
|
|
126
130
|
for. Do not hand the user a CSP that breaks their site — present as draft.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-islands
|
|
3
|
-
version: 1.0
|
|
3
|
+
version: 1.1.0
|
|
4
4
|
description: |
|
|
5
5
|
Audit pracht hydration: over-hydrated routes that should be `hydration:
|
|
6
6
|
"islands"` or `"none"`, dead interactivity outside the islands directory,
|
|
@@ -89,9 +89,22 @@ there is no wrapper to mark a component elsewhere as an island.
|
|
|
89
89
|
Island props are serialized to JSON in the HTML. At each island call site on
|
|
90
90
|
an islands route, flag props that are functions, symbols, bigints, class
|
|
91
91
|
instances (`Date`, `Map`, ...), JSX elements, or circular — rendering throws a
|
|
92
|
-
descriptive error naming the offending prop path.
|
|
93
|
-
island
|
|
94
|
-
|
|
92
|
+
descriptive error naming the offending prop path. Children passed into an
|
|
93
|
+
island are fine: markup renders once on the server as static HTML, and plain
|
|
94
|
+
text arrives as a string value. Flag:
|
|
95
|
+
|
|
96
|
+
- a render function passed as children (it throws);
|
|
97
|
+
- children the island expects to re-render or pass props into, and islands
|
|
98
|
+
among the children that read context the outer island provides (the browser
|
|
99
|
+
gives them the default) — those belong inside the island or need a prop;
|
|
100
|
+
- an island that splits markup children with `toChildArray`, `Children.map`, or
|
|
101
|
+
`Children.count` (tabs, carousels): it receives one opaque node, so labels
|
|
102
|
+
belong in props and panels are toggled through a ref (`hidden`);
|
|
103
|
+
- an island that renders `children` inside `<p>`, `<a>`, or `<button>` while
|
|
104
|
+
call sites pass block elements, links, or buttons (the dev server throws
|
|
105
|
+
when it sees this), or raw HTML in children that may be malformed: the
|
|
106
|
+
browser moves those nodes out of the slot and the island is left unhydrated
|
|
107
|
+
with a console error.
|
|
95
108
|
|
|
96
109
|
### 3d. Hydration strategy tuning (`info`)
|
|
97
110
|
|
|
@@ -115,7 +128,28 @@ navigation to, from, and between them is full-document (MPA-style), and
|
|
|
115
128
|
route-state prefetching is skipped for them. Flag apps that rely on
|
|
116
129
|
client-side state surviving navigation across such routes (in-memory stores,
|
|
117
130
|
module-level caches shared between pages) — every navigation is a fresh
|
|
118
|
-
document.
|
|
131
|
+
document, unless `pracht({ client: { islandsNavigation: true } })` is set. With
|
|
132
|
+
it, links between islands pages (and from them to `none` pages) swap the page
|
|
133
|
+
into the same document and an island rendered with identical props on both
|
|
134
|
+
pages keeps its state; module state survives too. Still flag state that must
|
|
135
|
+
survive a link to a full-hydration route, a reload, or a browser without the
|
|
136
|
+
Navigation API, since those remain full loads. Page-change animation does not need the client router: with
|
|
137
|
+
`defineApp({ viewTransitions: true })` these documents carry a
|
|
138
|
+
`@view-transition` rule and animate as cross-document view transitions, so
|
|
139
|
+
flag only apps that load extra JavaScript to animate such navigations.
|
|
140
|
+
|
|
141
|
+
### 3g. Personal data in islands on cached pages (`warn`)
|
|
142
|
+
|
|
143
|
+
Island props on an `ssg`/`isg` route are fixed at build or regeneration time
|
|
144
|
+
and shared by every visitor. Flag islands that exist only to fetch per-visitor
|
|
145
|
+
content after load (a hand-rolled `fetch` of the session, cart, or "signed in
|
|
146
|
+
as" data in `useEffect`) and islands whose props look personal. Suggest a
|
|
147
|
+
server island instead (`src/server-islands/`, see
|
|
148
|
+
<https://pracht.resynapse.dev/docs/server-islands>): its
|
|
149
|
+
loader runs per request with the route's middleware context, and it may
|
|
150
|
+
contain islands, which hydrate once the server island is swapped in. On
|
|
151
|
+
full-hydration pages islands inside a server island stay static HTML — flag those
|
|
152
|
+
(`warn`) if they are expected to be interactive.
|
|
119
153
|
|
|
120
154
|
## Step 4: Report
|
|
121
155
|
|
|
@@ -139,5 +173,7 @@ finding.
|
|
|
139
173
|
routes.
|
|
140
174
|
4. Verify hydration in a running app via `html[data-pracht-islands-hydrated="true"]`
|
|
141
175
|
(set after all `load` islands hydrate) and per-island `data-hydrated="true"`.
|
|
176
|
+
Server islands report `html[data-pracht-server-islands-ready="true"]` once every server
|
|
177
|
+
island fetch the page started has settled, on every hydration mode.
|
|
142
178
|
|
|
143
179
|
$ARGUMENTS
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-loaders
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Audit pracht route loaders for serializability, leaked secrets, unsafe
|
|
6
6
|
`loaderCache`, browser-only API use, and missing `AbortSignal` plumbing.
|
|
@@ -38,13 +38,22 @@ manifest (`RouteConfig.loader`); the inspect JSON surfaces that as
|
|
|
38
38
|
`loaderFile` (null when the loader lives in the route module itself). Reading
|
|
39
39
|
only `file` misses every externalized loader.
|
|
40
40
|
|
|
41
|
+
Shells can export a `loader` too. Read each distinct `shellFile` from the same
|
|
42
|
+
JSON and audit its `loader` like a route's: its return value reaches the
|
|
43
|
+
browser as `shellData` on every page under that shell.
|
|
44
|
+
|
|
41
45
|
## Step 2: Run the five checks
|
|
42
46
|
|
|
43
47
|
For each `loader` (and `getStaticPaths` when present):
|
|
44
48
|
|
|
45
49
|
### 2a. Serializability
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
Skip this check for `hydration: "islands"` and `"none"` routes: they ship no
|
|
52
|
+
loader data. Otherwise, check `vite.config.*` for
|
|
53
|
+
`pracht({ client: { richData: true } })` first.
|
|
54
|
+
|
|
55
|
+
Without `richData` (the default), loader data is `JSON.stringify`'d while the
|
|
56
|
+
component's types still promise the original. Flag returns that contain any of:
|
|
48
57
|
|
|
49
58
|
| Construct | Why it breaks |
|
|
50
59
|
| ---------------------------- | ----------------------------------- |
|
|
@@ -57,6 +66,13 @@ Flag returns that contain any of:
|
|
|
57
66
|
| `bigint` | `JSON.stringify` throws |
|
|
58
67
|
| `undefined` in arrays/object | Drops keys; arrays become `null` |
|
|
59
68
|
|
|
69
|
+
With `richData`, `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, `undefined`,
|
|
70
|
+
and shared or circular references arrive intact: do not flag them. Flag
|
|
71
|
+
functions, symbols, typed arrays, and class instances without `toJSON()`
|
|
72
|
+
(`Error` included) instead: each fails the request with a 500 naming the path,
|
|
73
|
+
in production too. A class instance with `toJSON()` arrives as its output, so
|
|
74
|
+
its type lies.
|
|
75
|
+
|
|
60
76
|
A bare promise in loader data is always a bug — it serializes to `{}`. The fix
|
|
61
77
|
is `defer(promise)`, which marks the field as deferred and is read in the
|
|
62
78
|
component with `use()` inside a `<Suspense>` boundary. Flag a bare promise as an
|
|
@@ -73,7 +89,8 @@ Two `defer()` rules worth checking while you are in the loader:
|
|
|
73
89
|
behind a getter. An unresolved marker throws during serialization.
|
|
74
90
|
|
|
75
91
|
Recommend converting to `string` (ISO for dates), plain arrays, or plain objects
|
|
76
|
-
before return.
|
|
92
|
+
before return, or turning on `client.richData` when an app returns many dates
|
|
93
|
+
or maps.
|
|
77
94
|
|
|
78
95
|
### 2b. Secret leaks
|
|
79
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-redirects
|
|
3
|
-
version: 1.1.
|
|
3
|
+
version: 1.1.2
|
|
4
4
|
description: |
|
|
5
5
|
Find open redirects in pracht loaders, middleware, and navigation. The framework
|
|
6
6
|
rejects unsafe schemes and CRLF at both ends, but a hand-rolled 3xx Response
|
|
@@ -86,16 +86,18 @@ For each request-derived target, look for one of:
|
|
|
86
86
|
| Gate | Safe? |
|
|
87
87
|
| -------------------------------------- | ----- |
|
|
88
88
|
| Hardcoded allowlist of paths/origins | Yes |
|
|
89
|
-
| `target.startsWith('/')` AND `!target.startsWith('//')` | Yes — same-origin path only |
|
|
90
89
|
| `new URL(target, base).origin === url.origin` | Yes — origin comparison |
|
|
90
|
+
| `target.startsWith('/')` AND `!target.startsWith('//')` | **Bypassable** (`/\evil.example`) |
|
|
91
91
|
| `new URL(target).hostname === expected` | Yes if `expected` is trusted |
|
|
92
92
|
| No check | **Open redirect** |
|
|
93
93
|
| `target.includes(domain)` (substring) | **Bypassable** (`evil.com#yourdomain.com`) |
|
|
94
94
|
| Regex without anchors | **Likely bypassable** |
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
Prefix checks are **not** sufficient. `//evil.example/path` is
|
|
97
|
+
protocol-relative, and URL parsing turns `\` into `/` and strips tabs and
|
|
98
|
+
newlines, so `/\evil.example` and `/\t/evil.example` pass
|
|
99
|
+
`startsWith('/') && !startsWith('//')` yet land on another origin. Only an
|
|
100
|
+
origin comparison after `new URL(target, url)` is a real gate.
|
|
99
101
|
|
|
100
102
|
Remember: `redirect()`'s built-in validation covers scheme and CRLF only — it
|
|
101
103
|
happily redirects to any well-formed `http(s)` origin, so Class A sites still
|
|
@@ -118,9 +120,21 @@ Severity is the primary scale; the verdict is a secondary domain label:
|
|
|
118
120
|
For each `open`/`risky` finding, propose a fix snippet, e.g.:
|
|
119
121
|
|
|
120
122
|
```ts
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
|
|
124
|
+
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
|
|
125
|
+
try {
|
|
126
|
+
const target = new URL(value, base);
|
|
127
|
+
return target.origin === base.origin
|
|
128
|
+
? `${target.pathname}${target.search}${target.hash}`
|
|
129
|
+
: fallback;
|
|
130
|
+
} catch {
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return redirect(safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"), {
|
|
136
|
+
request,
|
|
137
|
+
});
|
|
124
138
|
```
|
|
125
139
|
|
|
126
140
|
## Step 5: Cross-check with the framework guards
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-secrets
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: |
|
|
5
5
|
Detect environment variables and secrets reaching the client bundle via loader
|
|
6
6
|
return values, hydration state, or server-only modules imported from client code
|
|
@@ -74,8 +74,9 @@ risk in the report.
|
|
|
74
74
|
|
|
75
75
|
## Step 3: Module import boundaries
|
|
76
76
|
|
|
77
|
-
Grep client-rendered files (route components, shells,
|
|
78
|
-
them) for
|
|
77
|
+
Grep client-rendered files (route components, shells, the app root registered
|
|
78
|
+
as `defineApp({ root })` or `pages/_root.tsx`, anything imported from them) for
|
|
79
|
+
imports of:
|
|
79
80
|
|
|
80
81
|
- `node:*` builtins
|
|
81
82
|
- `@pracht/adapter-*`
|
|
@@ -87,12 +88,22 @@ The Vite plugin strips the server-only exports `loader`, `head`, `headers`,
|
|
|
87
88
|
query (`middleware` is not a route-file export — middleware lives in the
|
|
88
89
|
manifest); see the client module transform notes in `docs/ARCHITECTURE.md`
|
|
89
90
|
and `docs/ENV.md`. But a component that imports `../server/db` will still
|
|
90
|
-
pull `db` into the client bundle. Flag those imports.
|
|
91
|
+
pull `db` into the client bundle. Flag those imports. The app root's browser
|
|
92
|
+
copy loses only `dehydrate` and the imports nothing else uses; `setup()`,
|
|
93
|
+
`Root`, `hydrate()`, and everything they import ship. A `dehydrate` bound in
|
|
94
|
+
the same destructuring as those (`export const { setup, Root, dehydrate,
|
|
95
|
+
hydrate } = createRoot(…)`) or reached through `export *` ships with them, so
|
|
96
|
+
flag a server-only value passed into that call. A root whose other code calls
|
|
97
|
+
or re-exports `dehydrate` ships whole; flag its server-only imports too.
|
|
91
98
|
|
|
92
99
|
## Step 4: Hidden surfaces
|
|
93
100
|
|
|
94
101
|
Check for accidental exposure outside loaders:
|
|
95
102
|
|
|
103
|
+
- The app root's `dehydrate()` snapshot: it lands in `pracht-state` as `root`
|
|
104
|
+
and in every route-state response, like loader data. For `@pracht/query`
|
|
105
|
+
that is every successful query cached during the request, so flag a
|
|
106
|
+
`queryFn` or `setQueryData` in a loader that caches a secret-bearing value.
|
|
96
107
|
- `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
|
|
97
108
|
- `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
|
|
98
109
|
document headers enter `dist/server/headers-manifest.json`; every serverful
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: audit-shells
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.4.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
|
|
@@ -92,6 +92,22 @@ route `headers()`). Optional, but if present:
|
|
|
92
92
|
- Flag shells whose `headers()` returns `undefined` unconditionally — delete
|
|
93
93
|
the export.
|
|
94
94
|
|
|
95
|
+
### 2g. `loader` export
|
|
96
|
+
|
|
97
|
+
Shells may export `loader(args)` for layout-level data, read with
|
|
98
|
+
`useShellData()` in the shell and its routes. If present:
|
|
99
|
+
- Run the audit-loaders checks on it: its return value is serialized into
|
|
100
|
+
every page under the shell (`shellData` in the hydration state and the
|
|
101
|
+
route-state JSON).
|
|
102
|
+
- `useShellData()` returns `undefined` while the shell renders without its
|
|
103
|
+
data (SPA loading state, an error boundary after the shell loader failed).
|
|
104
|
+
Flag a shell that dereferences it unguarded.
|
|
105
|
+
- Flag a shell loader used as an auth gate (redirecting anonymous users) with
|
|
106
|
+
no gating middleware on its routes: navigations inside the shell ask the
|
|
107
|
+
server to skip it. Gate in middleware.
|
|
108
|
+
- Flag several routes under one shell whose loaders each fetch the same
|
|
109
|
+
layout-level value (the current user): that belongs in the shell loader.
|
|
110
|
+
|
|
95
111
|
## Step 3: Coverage and waste
|
|
96
112
|
|
|
97
113
|
Shell registration is mode-aware — check the `mode` field from the Step 1
|