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 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` -> `tsc --noEmit`
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.0",
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
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: add-auth
3
- version: 2.1.0
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 requested = String(form.get("redirect") ?? "/dashboard");
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: requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard",
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 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.
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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: add-db
3
- version: 1.2.0
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 (the pattern in
97
- `examples/docs/src/routes/docs/recipes-fullstack-cloudflare.md`):
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.1
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
- - On Cloudflare Workers, a batching exporter must flush within the request or
278
- be handed the execution context by app code
279
- (`context.executionContext.waitUntil(exporter.flush())`). Pracht does not
280
- call `ctx.waitUntil()` for a sink.
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.4.0
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.1
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. Passing children into an
93
- island from a server component also throws (unsupported in v1): move the
94
- content inside the island or pass a serializable prop.
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.1.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
- Flag returns that contain any of:
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.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
- `startsWith('/')` alone is **not** sufficient — `//evil.example/path` parses
97
- as a protocol-relative URL and most browsers treat it as cross-origin. Require
98
- both `startsWith('/')` AND `!startsWith('//')`, or use `URL` parsing.
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
- const raw = url.searchParams.get("redirect") ?? "/dashboard";
122
- const safe = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";
123
- return redirect(safe, { request });
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.1.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, anything imported from
78
- them) for imports of:
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.0
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