Claude Skill

audit-loaders

Audit pracht route loaders for serializability, leaked secrets, unsafe `loaderCache`, browser-only API use, and missing `AbortSignal` plumbing. Use for "audit loaders", "check loader data", "find serialization bugs", "loader security review".

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Download jovidecroock-pracht-skills_audit-loaders-8bcdd52.zip · 3 KB

Install

skills CLI npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-loaders
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
Git git clone https://github.com/JoviDeCroock/pracht.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Pracht Audit Loaders

Static analysis of every loader in the project. The framework serializes loader return values to the client via window.__PRACHT_STATE__, so anything returned ends up in the browser — including secrets you never meant to expose.

Step 1: Enumerate routes

MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its inspect_routes/inspect_api/inspect_build/doctor/verify tools over shelling out.

pracht inspect routes --json

Prerequisite: pracht inspect needs a vite config with the pracht plugin wired up.

For every route entry, read loaderFile ?? file and inspect the loader export there. Loaders may live in a separate data module wired via the manifest (RouteConfig.loader); the inspect JSON surfaces that as loaderFile (null when the loader lives in the route module itself). Reading only file misses every externalized loader.

Shells can export a loader too. Read each distinct shellFile from the same JSON and audit its loader like a route's: its return value reaches the browser as shellData on every page under that shell.

Step 2: Run the five checks

For each loader (and getStaticPaths when present):

2a. Serializability

Skip this check for hydration: "islands" and "none" routes: they ship no loader data. Otherwise, check vite.config.* for pracht({ client: { richData: true } }) first.

Without richData (the default), loader data is JSON.stringify'd while the component's types still promise the original. Flag returns that contain any of:

Construct Why it breaks
Date, Map, Set, URL Not preserved by JSON.stringify
Class instances Lose prototype on the client
Function / arrow values Stripped silently
Promise (bare) Becomes {} — wrap in defer()
Circular refs Throws at serialize time
Buffer / typed arrays Becomes {} or numeric keys
bigint JSON.stringify throws
undefined in arrays/object Drops keys; arrays become null

With richData, Date, Map, Set, URL, RegExp, bigint, undefined, and shared or circular references arrive intact: do not flag them. Flag functions, symbols, typed arrays, and class instances without toJSON() (Error included) instead: each fails the request with a 500 naming the path, in production too. A class instance with toJSON() arrives as its output, so its type lies.

A bare promise in loader data is always a bug — it serializes to {}. The fix is defer(promise), which marks the field as deferred and is read in the component with use() inside a <Suspense> boundary. Flag a bare promise as an error and point at defer(); a defer()ed field is correct and must not be flagged.

Two defer() rules worth checking while you are in the loader:

  • A deferred value must not redirect, throw PrachtHttpError, or set response status or headers — by the time it settles the response is already committed. Auth belongs in middleware or the awaited part of the loader.
  • defer(await …) defeats the point and throws at runtime. Flag it.
  • defer() must be returned from an enumerable data property, not hidden behind a getter. An unresolved marker throws during serialization.

Recommend converting to string (ISO for dates), plain arrays, or plain objects before return, or turning on client.richData when an app returns many dates or maps.

2b. Secret leaks

Ownership note: deep secret scanning is owned by /audit-secrets and by pracht verify's env scan — keep this check brief and point the user there rather than triple-reporting the same findings. Here, only flag what falls out of reading the return value anyway:

Grep the loader body and anything it returns for:

  • process.env.* references that flow into the return value.
  • context.env.* (Cloudflare bindings) flowing into the return value.
  • Variables named *SECRET*, *TOKEN*, *KEY*, *PASSWORD*, *PRIVATE*, *API_KEY* reaching the return.
  • Spreads of full DB rows containing password_hash, mfa_secret, etc.

Loaders run server-side but the return value crosses the wire. Always project to a smaller shape before returning.

2c. Browser-only APIs at module top level or in loader

Flag any of these accessed unconditionally inside loader or at the top level of a route module that is rendered SSR/SSG/ISG:

  • window, document, navigator, localStorage, sessionStorage, IntersectionObserver, matchMedia, requestAnimationFrame.

These crash on the server. For SPA-only routes (render: "spa") it's fine inside the component — but never inside loader.

2d. AbortSignal plumbing

For loaders that call fetch or any I/O:

  • The framework passes signal in LoaderArgs.
  • Verify it is forwarded to outbound fetch(url, { signal }) calls and to any database client that accepts cancellation.
  • A loader that ignores signal keeps work running after the client navigates away, and holds the response past loaderTimeoutMs. Where a driver cannot take a signal, suggest defineApp({ enforceLoaderTimeout: true }) so the response still answers when the budget runs out.

2e. Loader cache safety

For routes with a positive loaderCache value, flag loader data whose freshness or visibility depends on cookies, authorization headers, sessions, user identity, permissions, or request-specific context. Route-state HTTP caching is private, so shared proxies cannot reuse it, but a stale response can still survive logout, account switching, or permission changes in the same browser.

Recommend loaderCache: false/0 for personalized or authorization-sensitive loaders. Use a positive duration only when every field in the returned data can be safely reused for that long. Do not confuse loaderCache with ISG revalidate or the short-lived in-memory prefetch cache; they are independent policies.

Step 3: Report

Produce a markdown table:

Route File Severity Finding Suggested fix

Severities: error (secret leak, crash), warn (serialization risk, missing signal), info (style nit).

Rules

  1. Use pracht inspect routes --json as the source of truth — do not glob src/routes/** and risk missing manifest wiring or catching orphan files.
  2. Read the actual loader source — do not infer from names.
  3. For each finding, point at the file and line.
  4. Do not auto-fix. Hand the user the report; let them choose.
  5. If the loader returns a typed shape from a DB ORM, recommend an explicit select or projection step rather than spreading the row.

$ARGUMENTS

Files (pracht)
  • SKILL.md 7.1 KB
    ---
    name: audit-loaders
    version: 1.2.0
    description: |
      Audit pracht route loaders for serializability, leaked secrets, unsafe
      `loaderCache`, browser-only API use, and missing `AbortSignal` plumbing.
      Use for "audit loaders", "check loader data", "find serialization bugs", "loader
      security review".
    allowed-tools:
      - Bash
      - Read
      - Grep
      - Glob
    ---
    
    # Pracht Audit Loaders
    
    Static analysis of every loader in the project. The framework serializes loader
    return values to the client via `window.__PRACHT_STATE__`, so anything returned
    ends up in the browser — including secrets you never meant to expose.
    
    ## Step 1: Enumerate routes
    
    MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its
    `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over
    shelling out.
    
    ```bash
    pracht inspect routes --json
    ```
    
    Prerequisite: `pracht inspect` needs a vite config with the pracht plugin
    wired up.
    
    For every route entry, read `loaderFile ?? file` and inspect the `loader`
    export there. Loaders may live in a separate data module wired via the
    manifest (`RouteConfig.loader`); the inspect JSON surfaces that as
    `loaderFile` (null when the loader lives in the route module itself). Reading
    only `file` misses every externalized loader.
    
    Shells can export a `loader` too. Read each distinct `shellFile` from the same
    JSON and audit its `loader` like a route's: its return value reaches the
    browser as `shellData` on every page under that shell.
    
    ## Step 2: Run the five checks
    
    For each `loader` (and `getStaticPaths` when present):
    
    ### 2a. Serializability
    
    Skip this check for `hydration: "islands"` and `"none"` routes: they ship no
    loader data. Otherwise, check `vite.config.*` for
    `pracht({ client: { richData: true } })` first.
    
    Without `richData` (the default), loader data is `JSON.stringify`'d while the
    component's types still promise the original. Flag returns that contain any of:
    
    | Construct                    | Why it breaks                       |
    | ---------------------------- | ----------------------------------- |
    | `Date`, `Map`, `Set`, `URL`  | Not preserved by `JSON.stringify`   |
    | Class instances              | Lose prototype on the client        |
    | `Function` / arrow values    | Stripped silently                   |
    | `Promise` (bare)             | Becomes `{}` — wrap in `defer()`    |
    | Circular refs                | Throws at serialize time            |
    | `Buffer` / typed arrays      | Becomes `{}` or numeric keys        |
    | `bigint`                     | `JSON.stringify` throws             |
    | `undefined` in arrays/object | Drops keys; arrays become `null`    |
    
    With `richData`, `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, `undefined`,
    and shared or circular references arrive intact: do not flag them. Flag
    functions, symbols, typed arrays, and class instances without `toJSON()`
    (`Error` included) instead: each fails the request with a 500 naming the path,
    in production too. A class instance with `toJSON()` arrives as its output, so
    its type lies.
    
    A bare promise in loader data is always a bug — it serializes to `{}`. The fix
    is `defer(promise)`, which marks the field as deferred and is read in the
    component with `use()` inside a `<Suspense>` boundary. Flag a bare promise as an
    `error` and point at `defer()`; a `defer()`ed field is correct and must not be
    flagged.
    
    Two `defer()` rules worth checking while you are in the loader:
    
    - A deferred value must not redirect, throw `PrachtHttpError`, or set response
      status or headers — by the time it settles the response is already committed.
      Auth belongs in middleware or the awaited part of the loader.
    - `defer(await …)` defeats the point and throws at runtime. Flag it.
    - `defer()` must be returned from an enumerable data property, not hidden
      behind a getter. An unresolved marker throws during serialization.
    
    Recommend converting to `string` (ISO for dates), plain arrays, or plain objects
    before return, or turning on `client.richData` when an app returns many dates
    or maps.
    
    ### 2b. Secret leaks
    
    Ownership note: deep secret scanning is owned by `/audit-secrets` and by
    `pracht verify`'s env scan — keep this check brief and point the user there
    rather than triple-reporting the same findings. Here, only flag what falls
    out of reading the return value anyway:
    
    Grep the loader body and anything it returns for:
    
    - `process.env.*` references that flow into the return value.
    - `context.env.*` (Cloudflare bindings) flowing into the return value.
    - Variables named `*SECRET*`, `*TOKEN*`, `*KEY*`, `*PASSWORD*`, `*PRIVATE*`,
      `*API_KEY*` reaching the return.
    - Spreads of full DB rows containing `password_hash`, `mfa_secret`, etc.
    
    Loaders run server-side but **the return value crosses the wire**. Always
    project to a smaller shape before returning.
    
    ### 2c. Browser-only APIs at module top level or in loader
    
    Flag any of these accessed unconditionally inside `loader` or at the top level
    of a route module that is rendered SSR/SSG/ISG:
    
    - `window`, `document`, `navigator`, `localStorage`, `sessionStorage`,
      `IntersectionObserver`, `matchMedia`, `requestAnimationFrame`.
    
    These crash on the server. For SPA-only routes (`render: "spa"`) it's fine
    inside the component — but never inside `loader`.
    
    ### 2d. AbortSignal plumbing
    
    For loaders that call `fetch` or any I/O:
    
    - The framework passes `signal` in `LoaderArgs`.
    - Verify it is forwarded to outbound `fetch(url, { signal })` calls and to any
      database client that accepts cancellation.
    - A loader that ignores `signal` keeps work running after the client navigates
      away, and holds the response past `loaderTimeoutMs`. Where a driver cannot
      take a signal, suggest `defineApp({ enforceLoaderTimeout: true })` so the
      response still answers when the budget runs out.
    
    ### 2e. Loader cache safety
    
    For routes with a positive `loaderCache` value, flag loader data whose freshness
    or visibility depends on cookies, authorization headers, sessions, user identity,
    permissions, or request-specific context. Route-state HTTP caching is `private`,
    so shared proxies cannot reuse it, but a stale response can still survive logout,
    account switching, or permission changes in the same browser.
    
    Recommend `loaderCache: false`/`0` for personalized or authorization-sensitive
    loaders. Use a positive duration only when every field in the returned data can be
    safely reused for that long. Do not confuse `loaderCache` with ISG `revalidate` or
    the short-lived in-memory prefetch cache; they are independent policies.
    
    ## Step 3: Report
    
    Produce a markdown table:
    
    | Route | File | Severity | Finding | Suggested fix |
    | ----- | ---- | -------- | ------- | ------------- |
    
    Severities: `error` (secret leak, crash), `warn` (serialization risk, missing
    signal), `info` (style nit).
    
    ## Rules
    
    1. Use `pracht inspect routes --json` as the source of truth — do not glob
       `src/routes/**` and risk missing manifest wiring or catching orphan files.
    2. Read the actual loader source — do not infer from names.
    3. For each finding, point at the file and line.
    4. Do not auto-fix. Hand the user the report; let them choose.
    5. If the loader returns a typed shape from a DB ORM, recommend an explicit
       `select` or projection step rather than spreading the row.
    
    $ARGUMENTS
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.