Просмотр исходного кода

feat(skills): Add hono-ops skill

Hono v4 on Cloudflare Workers, distilled from a production multi-tenant
Worker: app composition + sub-app mounting, middleware-ordering-as-security-
topology, typed errors + onError, zValidator vs hand-rolled validation, SPA
co-serving, hc RPC vs typed clients, vitest-pool-workers testing, streaming/
SSE/WebSockets, and Workers runtime gotchas.

Ships 8 references, a commented composition-root starter template, a
route-inventory.py scanner + middleware-order linter (exit 10 = routes that
bypass a later-registered middleware), a check-hono-facts.py staleness
verifier (--offline in PR CI, --live in freshness.yml), and a 50-assertion
offline suite.

Repo wiring: README row + count bumps (103), AGENTS.md/PLAN.md counts,
CHANGELOG entry, check-resources.sh offline assertions, freshness.yml live
drift step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 месяц назад
Родитель
Сommit
5e9203dbcf

+ 9 - 0
.github/workflows/freshness.yml

@@ -115,6 +115,15 @@ jobs:
           if [ "$rc" -eq 7 ]; then echo "::warning::isometric-ops live check unreachable (npm registry) — skipped"; fi
           exit 0
 
+      - name: hono-ops packages vs npm registry (existence + hono major)
+        run: |
+          set +e
+          python skills/hono-ops/scripts/check-hono-facts.py --live
+          rc=$?
+          if [ "$rc" -eq 10 ]; then echo "::error::hono-ops drift — a named npm package is gone, or hono shipped a major past v4"; exit 1; fi
+          if [ "$rc" -eq 7 ]; then echo "::warning::hono-ops live check unreachable (npm registry) — skipped"; fi
+          exit 0
+
       - name: GitHub Action refs still resolve
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **3 expert agents** for pure context-isolation/worker roles (git-agent, firecrawl-expert, project-organizer) - every domain-knowledge agent became an `-ops` skill (v3.0, skills-first)
 - **3 commands** for session management and git orchestration (/sync, /save, /git-ops)
-- **102 skills** for CLI tools, patterns, workflows, and development tasks (incl. `rembg-ops` for transparent-PNG cutouts of flat illustration/sticker/avatar art with a deterministic fallback ladder past rembg's ML failure modes; `parallel-ops` as the router for the parallel/recurring-agent-work family — fleet-ops, fleet-worker, fleetflow (extracted to `X:\Forge\fleetflow`, mounted as a skill via junction), loop-ops, iterate, spawn — read it first when that family is ambiguous; `repo-doctor` for agentic-quality repo audits; `svg-brand-tint-ops` for zero-dep in-browser SVG brand-recolour + Potrace-stage raster vectorising; `r-ops` for tidyverse-first modern R / data analysis; `loop-ops` for outer-loop design discipline; `ffmpeg-ops` for probe-first media processing and EDL-driven editing; `supply-chain-defense` for behavioural-first dependency security; `prompt-injection-defense` for instruction-integrity scanning; `pypi-ops` for OIDC Trusted Publishing to PyPI; `net-ops` for network troubleshooting; `windows-ops` / `mac-ops` for workstation diagnostics; `fleet-worker` for cheap parallel worker delegation)
+- **103 skills** for CLI tools, patterns, workflows, and development tasks (incl. `rembg-ops` for transparent-PNG cutouts of flat illustration/sticker/avatar art with a deterministic fallback ladder past rembg's ML failure modes; `parallel-ops` as the router for the parallel/recurring-agent-work family — fleet-ops, fleet-worker, fleetflow (extracted to `X:\Forge\fleetflow`, mounted as a skill via junction), loop-ops, iterate, spawn — read it first when that family is ambiguous; `repo-doctor` for agentic-quality repo audits; `svg-brand-tint-ops` for zero-dep in-browser SVG brand-recolour + Potrace-stage raster vectorising; `r-ops` for tidyverse-first modern R / data analysis; `loop-ops` for outer-loop design discipline; `ffmpeg-ops` for probe-first media processing and EDL-driven editing; `supply-chain-defense` for behavioural-first dependency security; `prompt-injection-defense` for instruction-integrity scanning; `pypi-ops` for OIDC Trusted Publishing to PyPI; `net-ops` for network troubleshooting; `windows-ops` / `mac-ops` for workstation diagnostics; `fleet-worker` for cheap parallel worker delegation)
 - **13 output styles** for response personality (Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout)
 - **13 hooks** for pre-commit linting, post-edit formatting, dangerous command warnings, uv enforcement, dependency-install + manifest-edit supply-chain advisories, hidden-Unicode scanning (session-start + pre-commit), live config-change + worktree guards, mid-session peer-writer guard + touched-files ledger, and pmail notifications - security set auto-wired via plugin hooks.json
 - **Pigeon** inter-session messaging (`pigeon send/read/reply`) - SQLite-backed pmail at `~/.claude/pmail.db`

+ 17 - 0
CHANGELOG.md

@@ -76,6 +76,23 @@ feature releases live in the README "Recent Updates" section.
   the decision tree and body gain an IAP branch + quick reference, and
   cloudflare-ops cross-points to the new Access reference.
 
+- **`hono-ops` skill** - Hono v4 on Cloudflare Workers, distilled from a
+  production multi-tenant Worker (one app, 6+ mounted sub-apps, ~1350 tests):
+  app composition with typed `Bindings`/`Variables` and sub-app mounting,
+  middleware ordering as the security topology (auth middleware that builds a
+  scoped per-request world; bearer-auth surfaces mounted *outside* the session
+  boundary), typed errors → one `onError` mapping, the JSON-404-vs-SPA-shell
+  split, zValidator vs hand-rolled validation trade-offs, SPA co-serving via
+  the static assets binding, `hc` RPC vs hand-rolled typed clients,
+  vitest-pool-workers testing (migrations, JWT harness, workerd version lag),
+  streaming/SSE/WebSockets, and the Workers gotchas (detached fetch "Illegal
+  invocation", immutable headers, per-colo `caches`, `waitUntil`). Eight
+  references, a commented composition-root starter template, a
+  `route-inventory.py` scanner with a middleware-order linter (exit 10 =
+  routes that bypass a later-registered middleware), a `check-hono-facts.py`
+  staleness verifier (offline in PR CI, live in the freshness workflow), and
+  a 50-assertion offline suite.
+
 
 ### Removed
 - **`fleetflow` skill extracted to its own repo** (`X:\Forge\fleetflow`) with

+ 5 - 4
README.md

@@ -12,13 +12,13 @@
 
 > *A comprehensive extension toolkit that transforms Claude Code into a specialized development powerhouse.*
 
-**claude-mods** is a production-ready plugin that extends Claude Code with 102 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
+**claude-mods** is a production-ready plugin that extends Claude Code with 103 specialized skills, 3 expert agents, 13 output styles, 13 hooks, and modern CLI tools designed for real-world development workflows. Whether you're debugging React hooks, optimizing PostgreSQL queries, or building production CLI applications, this toolkit equips Claude with the domain expertise and procedural knowledge to work at expert level across multiple technology stacks.
 
 Built on the [Agent Skills specification](https://agentskills.io/specification) (an open standard backed by Anthropic, Vercel, Google, Microsoft, and 40+ agent platforms), claude-mods fills critical gaps in Claude Code's capabilities: persistent session state that survives across machines, on-demand expert knowledge for specialized domains, token-efficient modern CLI tools (10-100x faster than traditional alternatives), and proven workflow patterns for TDD, code review, and feature development. The toolkit implements Anthropic's [recommended patterns for long-running agents](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents), ensuring your development context never vanishes when sessions end.
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**3 agents. 102 skills. 13 styles. 13 hooks. 14 rules. One install.**
+**3 agents. 103 skills. 13 styles. 13 hooks. 14 rules. One install.**
 
 ## Recent Updates
 
@@ -88,7 +88,7 @@ Claude Code is powerful out of the box, but it has gaps. This toolkit fills them
 
 - **Session continuity** — Tasks vanish when sessions end. We fix that with `/save` and `/sync`, implementing Anthropic's [recommended pattern](https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents) for long-running agents.
 
-- **Expert-level knowledge on demand** — 102 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
+- **Expert-level knowledge on demand** — 103 on-demand skills covering React, TypeScript, Python, Go, Rust, PostgreSQL, and more, plus 3 specialized agents reserved for genuine context-isolation/worker roles (git operations, web scraping, project reorganization). Skills-first: knowledge loads when relevant instead of living in heavyweight agent prompts.
 
 - **Modern CLI tools** — Stop using `grep`, `find`, and `cat`. Our rules automatically prefer `ripgrep`, `fd`, `eza`, and `bat` — 10-100x faster and token-efficient.
 
@@ -113,7 +113,7 @@ claude-mods/
 ├── .claude-plugin/     # Plugin metadata
 ├── agents/             # Expert subagents (3)
 ├── commands/           # Slash commands (3)
-├── skills/             # Custom skills (102)
+├── skills/             # Custom skills (103)
 ├── output-styles/      # Response personalities
 ├── hooks/              # Hook examples & docs
 ├── rules/              # Claude Code rules
@@ -214,6 +214,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [laravel-ops](skills/laravel-ops/) | Laravel Eloquent, architecture, authentication, testing with Pest |
 | [craftcms-ops](skills/craftcms-ops/) | Craft CMS 5 - entries/sections/fields, Matrix-as-entries, Twig, element queries, GraphQL, plugins |
 | [payloadcms-ops](skills/payloadcms-ops/) | Payload CMS 3 (Next.js-native) - collections/globals, Local API, access control, hooks, fields |
+| [hono-ops](skills/hono-ops/) | Hono v4 on Cloudflare Workers - app composition + sub-app mounting, middleware ordering/auth boundaries, typed errors + onError, zValidator vs hand-rolled validation, SPA co-serving, hc RPC vs typed clients, vitest-pool-workers testing, streaming/SSE/WebSockets; route-inventory + middleware-order linter, staleness verifier |
 | [cli-ops](skills/cli-ops/) | Production CLI tool patterns - agentic workflows, stream separation, exit codes |
 | [bash-ops](skills/bash-ops/) | Defensive Bash - strict mode, traps, safe argument parsing, semantic exit codes, shellcheck, CI scripts |
 | [cypress-ops](skills/cypress-ops/) | Cypress e2e + component testing - data-test selectors, cy.intercept, cy.session, Test Replay, flake diagnosis |

+ 1 - 1
docs/PLAN.md

@@ -16,7 +16,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 3 | Pure context-isolation/worker roles only: git-agent (background commits/PRs), firecrawl-expert (noisy scrapes), project-organizer (bulk restructure) |
-| Skills | 102 | Operational skills, CLI tools, workflows, diagnostics, security |
+| Skills | 103 | Operational skills, CLI tools, workflows, diagnostics, security |
 | Commands | 3 | Session management + git orchestration (sync, save, git-ops) |
 | Rules | 14 | agentic-quality, cli-tools, commit-style, dev-servers, loop-engineering, modern-tools, naming-conventions, prompt-injection, public-posts, release-review, shell-preference, skill-agent-updates, supply-chain, worktree-boundaries |
 | Output Styles | 13 | Vesper, Spartan, Mentor, Executive, Pair, Atlas, Coach, Harbour, Meridian, Noir, Roast, Sage, Scout |

+ 262 - 0
skills/hono-ops/SKILL.md

@@ -0,0 +1,262 @@
+---
+name: hono-ops
+description: "Hono on Cloudflare Workers - composition, middleware, typed bindings, validation, RPC, streaming, testing. Use for: hono, hono middleware, app.route, hono rpc, c.env bindings, onError, zValidator, vitest-pool-workers, spa fallback worker."
+license: MIT
+allowed-tools: "Read Write Bash Grep Glob"
+metadata:
+  author: claude-mods
+  related-skills: "cloudflare-ops, typescript-ops, sqlite-ops, rest-ops, testing-ops, auth-ops"
+---
+
+# Hono Operations
+
+Hono on Cloudflare Workers: composing multi-app APIs in one Worker, middleware
+discipline, typed errors, validation at the HTTP boundary, SPA co-serving, RPC
+clients, and testing under vitest-pool-workers. Patterns here are distilled from a
+production multi-tenant Worker (one Hono app, 6+ mounted sub-apps, ~1350 tests).
+
+> Verified against Hono v4 (2026). Hono also runs on Bun/Deno/Node — this skill is
+> Workers-first; non-Workers deltas are noted where they matter.
+
+**Staleness check:** `python scripts/check-hono-facts.py --offline` asserts the
+version-bearing facts (Hono major, `@hono/zod-validator`,
+`@cloudflare/vitest-pool-workers`) are still named in the prose and the dated
+currency note above is present; `--live` confirms each package's npm major still
+matches. Catalog: `assets/hono-facts.json`.
+
+## Decision Tree
+
+```
+What are you doing with Hono?
+│
+├─ Structuring an app (generics, sub-apps, env typing)
+│  └─ Below + references/app-composition.md
+│
+├─ Middleware (ordering, auth, headers, exclusion boundaries)
+│  └─ Below + references/middleware.md
+│
+├─ Errors / 404s / request validation
+│  └─ Below + references/errors-validation.md
+│
+├─ Path syntax, routers, c.req/c.res surface, cookies
+│  └─ references/routing-and-request.md
+│
+├─ Serving a SPA / static assets from the same Worker
+│  └─ references/workers-runtime.md
+│
+├─ Cron / queues alongside fetch; runtime gotchas
+│  └─ references/workers-runtime.md
+│
+├─ Streaming / SSE / WebSockets / proxying / service bindings
+│  └─ references/streaming-and-realtime.md
+│
+├─ Typed client (hc RPC vs hand-rolled)
+│  └─ references/rpc-clients.md
+│
+├─ Testing (app.request, pool-workers, middleware isolation)
+│  └─ references/testing.md
+│
+├─ Starting a new Worker from scratch
+│  └─ assets/worker-template.ts (commented composition-root skeleton)
+│
+└─ Auditing an existing app's routes / middleware order
+   └─ scripts/route-inventory.py (below)
+```
+
+## App Composition (the 80%)
+
+Type the app once with `Bindings` (wrangler-provided env) and `Variables`
+(per-request context you `c.set`):
+
+```typescript
+import { Hono } from 'hono';
+
+interface Env {
+  DB: D1Database;
+  ASSETS: Fetcher;          // static assets binding (SPA)
+  API_KEYS?: string;        // optional secret: gate features on presence, 503 when unset
+}
+type Vars = { identity: Identity; repo: ScopedRepository };
+
+export const app = new Hono<{ Bindings: Env; Variables: Vars }>();
+```
+
+- `c.env.DB` — bindings, typed via `Bindings`.
+- `c.set('identity', id)` / `c.get('identity')` / `c.var.identity` — per-request
+  state, typed via `Variables`. Middleware writes it; handlers read it.
+- Prefer the per-app `Variables` generic over global `ContextVariableMap`
+  augmentation; the map is app-wide and leaks types across unrelated sub-apps
+  (see [references/app-composition.md](references/app-composition.md)).
+
+**Sub-app mounting** — one Worker, many feature apps, each its own file:
+
+```typescript
+// src/time/api.ts
+export const timeApi = new Hono<{ Bindings: Env; Variables: Vars }>();
+timeApi.get('/entries', (c) => { /* identity + repo already in context */ });
+
+// src/index.ts — mounted under the auth middleware (see Middleware below)
+app.route('/api/time', timeApi);       // timeApi sees paths relative to the mount
+app.route('/api/time', billingApi);    // two sub-apps on one base is fine when
+                                       // their paths are disjoint — Hono matches across both
+```
+
+The mounted sub-app inherits nothing implicitly except position: whatever
+middleware was registered on a matching path *before* the mount runs first.
+Position IS the security boundary — see Middleware.
+
+## Middleware: Order Is the Contract
+
+Hono middleware is an onion — code before `await next()` runs inbound, code
+after runs outbound — and **registration order is matching order**. A middleware
+registered after a matching handler never runs for it.
+
+```typescript
+app.use('*', securityHeaders());        // 1. outermost: response hardening
+app.get('/api/health', (c) => c.json({ ok: true }));  // 2. before auth = unauthenticated
+
+app.use('/api/*', async (c, next) => {  // 3. auth: verify, then stash identity
+  if (c.req.path === '/api/health') return next();   // skip-list for exceptions
+  const user = await verifyAndResolve(c.req.raw, c.env);   // throws/403s on failure
+  if (!user) return c.json({ error: 'forbidden' }, 403);
+  c.set('identity', user);
+  c.set('repo', scopedRepo(c.env.DB, user));  // handlers never touch raw bindings
+  await next();
+});
+
+app.route('/api/time', timeApi);        // 4. inside the auth boundary
+app.route('/vesper', vesper);           // 5. OUTSIDE /api/* — bearer-key auth, on purpose
+app.route('/ingest', ingest);           // machine-to-machine, own auth in the sub-app
+
+app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));  // JSON 404 for API
+app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));             // SPA fallback, LAST
+```
+
+Two load-bearing rules:
+
+1. **Auth middleware verifies, then builds the request's whole world** (identity,
+   scoped repo/session) into context. Handlers read `c.get(...)` and can't reach
+   unscoped resources by construction.
+2. **Routes with a different auth model mount OUTSIDE the middleware's path
+   pattern** (`/vesper`, `/ingest/*` above), each carrying its own auth middleware.
+   Don't punch exemptions through session auth with flags — move the mount.
+
+Depth (skip-lists vs path shape, security headers + the immutable-headers trap,
+timing-safe bearer compare): [references/middleware.md](references/middleware.md).
+
+## Errors: One Typed Boundary
+
+Throw typed errors anywhere below the handler; map them to HTTP in exactly one
+place:
+
+```typescript
+export class AppError extends Error {
+  constructor(public readonly status: number, public readonly code: string, message: string) {
+    super(message); this.name = 'AppError';
+  }
+}
+export const NotFound  = (m = 'not found')  => new AppError(404, 'not_found', m);
+export const Forbidden = (m = 'forbidden')  => new AppError(403, 'forbidden', m);
+export const Conflict  = (m = 'version conflict, reload and retry') => new AppError(409, 'conflict', m);
+
+app.onError((err, c) => {
+  if (err instanceof AppError)    return c.json({ error: err.code, message: err.message }, err.status as 400);
+  if (err instanceof SyntaxError) return c.json({ error: 'bad_request', message: 'invalid JSON body' }, 400);
+  console.error('unhandled error', err);          // log the real thing…
+  return c.json({ error: 'internal' }, 500);      // …never leak it to the wire
+});
+```
+
+- Cross-scope access returns **404, not 403** — a 403 confirms the row exists in
+  someone else's scope.
+- Unmatched `/api/*` gets a JSON 404; everything else falls through to the SPA
+  shell. Never let an API typo return `index.html`.
+- `app.notFound()` exists but only fires when *nothing* matched — with a
+  catch-all SPA route it never runs; use the explicit two-route split above.
+
+Validation at the boundary (zValidator vs hand-rolled assertions, and when each
+wins): [references/errors-validation.md](references/errors-validation.md).
+
+## Testing Quickstart
+
+`app.request()` / `app.fetch()` run the real app — middleware, routing, errors —
+with no server:
+
+```typescript
+import { env } from 'cloudflare:test';   // vitest-pool-workers: real bindings
+import { app } from '../src/index';
+
+const res = await app.request('/api/health', {}, env);   // env = 3rd arg (Bindings)
+expect(res.status).toBe(200);
+```
+
+Under `@cloudflare/vitest-pool-workers` the test runs inside workerd with real
+D1/KV/R2 bindings from `defineWorkersConfig`. Full setup — migrations into the
+test DB, isolated storage, an Access-JWT signing harness, testing one middleware
+in isolation, and the workerd-version-lag trap:
+[references/testing.md](references/testing.md).
+
+## Route Inventory Script
+
+`scripts/route-inventory.py` statically scans a Hono TypeScript source tree and
+lists every route, middleware registration, and `app.route()` mount with
+`file:line` — plus `--check`, a middleware-order linter that flags handlers
+registered *before* a middleware whose path pattern covers them (those handlers
+silently bypass it: the #1 Hono ordering bug).
+
+```bash
+# Inventory a Worker's HTTP surface (TSV: kind, method, path, file:line)
+python skills/hono-ops/scripts/route-inventory.py src/
+
+# JSON envelope for downstream tooling
+python skills/hono-ops/scripts/route-inventory.py --json src/ | jq '.data[] | select(.kind=="mount")'
+
+# Lint middleware ordering: exit 10 = findings (routes that dodge a later middleware)
+python skills/hono-ops/scripts/route-inventory.py --check src/
+```
+
+Exit codes: `0` clean, `2` usage, `3` path not found, `10` order findings
+(`--check`). Regex-based on purpose — it needs no TypeScript compiler API and
+works on any checkout.
+
+## Gotchas (Workers-Specific)
+
+| Gotcha | Why | Fix |
+|---|---|---|
+| "Illegal invocation" on fetch | Calling `this.fetchImpl(...)` binds `this` to your object; global fetch requires no receiver | Detach first: `const doFetch = this.fetchImpl; await doFetch(url, ...)` |
+| Mutating `ASSETS.fetch` response headers throws | Any `fetch()`-derived Response has immutable headers in workerd | Rebuild: `new Response(res.body, { status, headers: new Headers(res.headers) })` |
+| `caches` API "cache" misses constantly | It's per-colo, not global — every PoP has its own | Treat as a short-TTL local collapse (poll-storm absorber), never as KV |
+| `waitUntil` work vanishes | Post-response work must be registered before the handler returns; unregistered promises are cancelled | `c.executionCtx.waitUntil(promise)` inside the handler |
+| Middleware doesn't run for a route | Registered after the handler — order is matching order | Register middleware first; verify with `route-inventory.py --check` |
+| `wrangler dev` host surprises | Dev rewrites the request host to the `[[routes]]` pattern | Pin `[dev] host` in wrangler config when auth branches on hostname |
+| Optional secret unset | Route depends on an env secret that isn't configured | Gate on presence: `if (!c.env.KEY) return c.json({ error: 'unavailable' }, 503)` |
+
+More depth (SPA assets config, `run_worker_first`, scheduled/queue handlers,
+per-cron branching): [references/workers-runtime.md](references/workers-runtime.md).
+
+## Reference Files
+
+| Reference | When to Load |
+|-----------|-------------|
+| [references/app-composition.md](references/app-composition.md) | Generics (`Bindings`/`Variables`), `ContextVariableMap` trade-offs, sub-app mounting semantics, `basePath`, env-shape design |
+| [references/middleware.md](references/middleware.md) | Onion model, ordering proofs, auth middleware that builds context, security headers, bearer-auth sub-apps outside the session boundary |
+| [references/errors-validation.md](references/errors-validation.md) | `onError` mapping, typed error classes, 404 strategy, zValidator vs hand-rolled validation trade-offs |
+| [references/routing-and-request.md](references/routing-and-request.md) | Router internals, path syntax (params/regex/optional/wildcards), matching precedence, `c.req`/response helpers, cookies (incl. signed), JSX/html |
+| [references/testing.md](references/testing.md) | `app.request()` patterns, vitest-pool-workers config (D1 migrations, bindings, isolation), JWT test harness, middleware-in-isolation |
+| [references/rpc-clients.md](references/rpc-clients.md) | `hc<AppType>` RPC client, chained-route inference requirement, when a hand-rolled typed client is the better call |
+| [references/workers-runtime.md](references/workers-runtime.md) | SPA/static assets from one Worker, `scheduled()` + queue handlers beside `fetch`, `waitUntil`, `caches`, detached fetch |
+| [references/streaming-and-realtime.md](references/streaming-and-realtime.md) | `stream`/`streamText`/`streamSSE`, WebSockets (plain Worker vs Durable Object hibernation), proxying, service bindings |
+
+**Starter asset:** [assets/worker-template.ts](assets/worker-template.ts) — a
+commented composition-root skeleton (typed env, security headers, auth
+middleware, bearer sub-app, 404 split, `onError`, cron) with adapt-points
+marked. Copy it as the seed of a new Worker; every section cross-refs the
+reference that explains it.
+
+## See Also
+
+- `cloudflare-ops` — wrangler config, bindings provisioning, deploy/CI
+- `sqlite-ops` — D1 specifics (sessions/bookmarks, batch semantics, query plans)
+- `typescript-ops` — generics, Zod 4, type-narrowing the payloads you validate
+- `rest-ops` / `api-design-ops` — endpoint and contract design above the framework
+- `auth-ops` — JWT/session/token theory behind the auth middleware patterns

+ 18 - 0
skills/hono-ops/assets/hono-facts.json

@@ -0,0 +1,18 @@
+{
+  "schema": "claude-mods.hono-ops.facts/v1",
+  "as_of": "2026-08-08",
+  "comment": "Version-bearing external facts this skill states as current. check-hono-facts.py --offline asserts each prose_token is still named in the skill prose; --live asserts the npm package still resolves and (where documented_major is set) its latest major still matches.",
+  "hono": {
+    "prose_token": "Hono v4",
+    "package": "hono",
+    "documented_major": "4"
+  },
+  "zod_validator": {
+    "prose_token": "@hono/zod-validator",
+    "package": "@hono/zod-validator"
+  },
+  "pool_workers": {
+    "prose_token": "@cloudflare/vitest-pool-workers",
+    "package": "@cloudflare/vitest-pool-workers"
+  }
+}

+ 123 - 0
skills/hono-ops/assets/worker-template.ts

@@ -0,0 +1,123 @@
+// hono-ops starter: a Hono v4 composition root for one Cloudflare Worker serving
+// an authed JSON API + a SPA, with a bearer-auth machine surface and cron beside it.
+// ADAPT-POINTS are marked <<< — everything else is the load-bearing skeleton.
+// Registration ORDER in this file is the security topology (see the skill's
+// middleware.md): middleware before the routes it must cover, catch-alls last.
+//
+// Pairs with wrangler config:
+//   assets = { directory: "./web/dist", binding: "ASSETS",
+//              not_found_handling: "single-page-application",
+//              run_worker_first: ["/api/*", "/ingest/*"] }
+//   triggers = { crons: ["*/5 * * * *"] }
+
+import { Hono } from 'hono';
+import type { MiddlewareHandler } from 'hono';
+
+// --- env: the Worker's configuration contract --------------------------------
+interface Env {
+  DB: D1Database;                 // <<< your bindings
+  ASSETS: Fetcher;                // static assets binding (the built SPA)
+  /** Comma-separated bearer keys for /ingest (two during rotation). Optional:
+   *  while unset the surface 503s — absence disables, never crashes. */
+  INGEST_KEYS?: string;
+}
+
+// Per-request context set by the auth middleware; handlers read ONLY this.
+type Identity = { userId: string; email: string; role: 'admin' | 'member' };
+type Vars = { identity: Identity };
+
+export const app = new Hono<{ Bindings: Env; Variables: Vars }>();
+
+// --- 1. outermost: response hardening (fills missing headers on EVERY response)
+function securityHeaders(): MiddlewareHandler {
+  return async (c, next) => {
+    await next();
+    // ASSETS.fetch responses have IMMUTABLE headers — copy, then rebuild.
+    const headers = new Headers(c.res.headers);
+    const setIfMissing = (n: string, v: string) => { if (!headers.has(n)) headers.set(n, v); };
+    setIfMissing('x-content-type-options', 'nosniff');
+    setIfMissing('x-frame-options', 'DENY');
+    setIfMissing('referrer-policy', 'no-referrer');
+    c.res = new Response(c.res.body, { status: c.res.status, statusText: c.res.statusText, headers });
+  };
+}
+app.use('*', securityHeaders());
+
+// --- 2. unauthenticated exceptions, registered BEFORE auth (deliberate bypass)
+app.get('/api/health', (c) => c.json({ ok: true }));
+
+// --- 3. auth: verify the credential, then build the request's world ----------
+app.use('/api/*', async (c, next) => {
+  if (c.req.path === '/api/health') return next();   // skip-list documents the exception
+  const identity = await verifyCaller(c.req.raw, c.env);   // <<< your JWT/session verify
+  if (!identity) return c.json({ error: 'forbidden' }, 403);
+  c.set('identity', identity);
+  await next();
+});
+
+// --- 4. routes + feature sub-app mounts (inside the auth boundary) -----------
+app.get('/api/me', (c) => c.json({ identity: c.get('identity') }));
+// app.route('/api/widgets', widgetsApi);   // <<< identity already set for sub-apps
+
+// --- 5. machine surface OUTSIDE /api/*: its own bearer auth, not session auth -
+const ingest = new Hono<{ Bindings: Env }>();
+ingest.use('*', async (c, next) => {
+  if (!c.env.INGEST_KEYS) return c.json({ error: 'unavailable' }, 503);
+  const token = (c.req.header('authorization') ?? '').replace(/^Bearer /, '');
+  const keys = c.env.INGEST_KEYS.split(',').map((k) => k.trim()).filter(Boolean);
+  if (!token || !keys.some((k) => timingSafeEqual(token, k))) {
+    return c.json({ error: 'unauthorized' }, 401);
+  }
+  await next();
+});
+ingest.post('/events', async (c) => {
+  const body = await c.req.json<{ events?: unknown[] }>().catch(() => ({}) as { events?: unknown[] });
+  if (!Array.isArray(body.events)) return c.json({ error: 'bad_request', message: 'events[] required' }, 400);
+  return c.json({ accepted: body.events.length }, 202);
+});
+app.route('/ingest', ingest);
+
+// --- 6. the 404 split: JSON for API typos, SPA shell for everything else -----
+app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));
+app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));
+
+// --- 7. one error boundary ---------------------------------------------------
+class AppError extends Error {
+  constructor(public readonly status: number, public readonly code: string, message: string) {
+    super(message); this.name = 'AppError';
+  }
+}
+app.onError((err, c) => {
+  if (err instanceof AppError) return c.json({ error: err.code, message: err.message }, err.status as 400);
+  if (err instanceof SyntaxError) return c.json({ error: 'bad_request', message: 'invalid JSON body' }, 400);
+  console.error('unhandled error', err);            // detail to logs,
+  return c.json({ error: 'internal' }, 500);        // generic to the wire
+});
+
+// --- export: fetch + cron in one Worker --------------------------------------
+export default {
+  fetch: app.fetch,
+  scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
+    // Branch per cron expression; each job independently waitUntil'd so one
+    // failure never suppresses a sibling. Jobs must be idempotent — crons re-run.
+    if (controller.cron === '*/5 * * * *') {
+      ctx.waitUntil(drainOutbox(env));               // <<< your jobs
+    }
+  },
+} satisfies ExportedHandler<Env>;
+
+// --- helpers (stubs — replace) -----------------------------------------------
+async function verifyCaller(_req: Request, _env: Env): Promise<Identity | null> {
+  throw new AppError(500, 'not_implemented', 'wire your JWT/session verification here'); // <<<
+}
+async function drainOutbox(_env: Env): Promise<void> {} // <<<
+function timingSafeEqual(a: string, b: string): boolean {
+  // Constant-time compare over a fixed length — an early-return compare leaks
+  // prefix length via timing.
+  const enc = new TextEncoder();
+  const ab = enc.encode(a), bb = enc.encode(b);
+  const len = Math.max(ab.length, bb.length, 1);
+  let diff = ab.length ^ bb.length;
+  for (let i = 0; i < len; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
+  return diff === 0;
+}

+ 145 - 0
skills/hono-ops/references/app-composition.md

@@ -0,0 +1,145 @@
+# App Composition — Typing, Sub-Apps, Mount Semantics
+
+How to structure a Hono app that stays navigable at 6+ feature areas in one
+Worker. Companion to SKILL.md's "App Composition" section; this file owns the
+depth.
+
+## The generics: `Bindings` and `Variables`
+
+```typescript
+import { Hono } from 'hono';
+
+interface Env {
+  DB: D1Database;
+  ASSETS: Fetcher;
+  /** Comma-separated bearer keys (two during rotation). Optional: absence
+   *  disables the dependent surface (503), it never crashes boot. */
+  VESPER_KEYS?: string;
+}
+
+type Vars = {
+  identity: Identity;        // set by auth middleware
+  repo: ScopedRepository;    // set by auth middleware; handlers use ONLY this
+};
+
+export const app = new Hono<{ Bindings: Env; Variables: Vars }>();
+```
+
+- `Bindings` types `c.env`. Keep the `Env` interface in one place and make
+  every optional integration key genuinely optional (`?:`) — the route gates on
+  presence and 503s, the cron no-ops. A required key that isn't bound crashes
+  every request, not just the feature.
+- `Variables` types `c.set` / `c.get` / `c.var`. `c.var.identity` is the
+  property-style accessor for `c.get('identity')`.
+- Document each env key at its declaration (what it is, whether it's a secret,
+  what happens when unset). The `Env` interface is the Worker's configuration
+  contract — treat it like one.
+
+### `ContextVariableMap` vs the `Variables` generic
+
+```typescript
+// Global augmentation — every Hono instance in the process sees this:
+declare module 'hono' {
+  interface ContextVariableMap {
+    requestId: string;
+  }
+}
+```
+
+| | `Variables` generic | `ContextVariableMap` |
+|---|---|---|
+| Scope | One app (and sub-apps you type the same) | Every Hono app in the build |
+| Fit | App-specific state (identity, repo) | Truly cross-cutting values set by a shared middleware package (request id, logger) |
+| Risk | Repeating the type in each sub-app file | Type leakage: unrelated apps "have" variables nothing set |
+
+Default to the generic. Reach for `ContextVariableMap` only when you publish a
+middleware whose consumers shouldn't have to thread a generic through.
+
+### Typing middleware helpers
+
+A standalone middleware factory uses `MiddlewareHandler` (optionally with the
+same env shape):
+
+```typescript
+import type { MiddlewareHandler } from 'hono';
+
+export function securityHeaders(): MiddlewareHandler {
+  return async (c, next) => { await next(); /* … */ };
+}
+```
+
+Use `createMiddleware<{ Bindings: Env; Variables: Vars }>()` (from
+`hono/factory`) when the middleware body needs the typed `c.env`/`c.var`.
+
+## Sub-app mounting with `app.route()`
+
+```typescript
+// Feature file exports a Hono instance typed with the SAME env shape:
+export const timeApi = new Hono<{ Bindings: Env; Variables: Vars }>();
+timeApi.get('/entries', (c) => c.json({ entries: [] }));   // path is mount-relative
+
+// Root file mounts it:
+app.route('/api/time', timeApi);   // serves GET /api/time/entries
+```
+
+Semantics that matter in practice:
+
+- **Paths are mount-relative.** The sub-app never knows its prefix; you can
+  remount it elsewhere (or in a test) without edits.
+- **Two sub-apps on one base path is legal** — Hono matches across both. Keep
+  their route sets disjoint; when they are, registration order between them is
+  irrelevant (say so in a comment where you mount them, or the next reader will
+  assume order is load-bearing).
+- **Mount position decides which middleware applies.** `app.route()` inside a
+  `app.use('/api/*', auth)` pattern's coverage runs behind auth; a mount at
+  `/vesper` outside it does not. There is no "inherit auth" flag — position is
+  the mechanism (see middleware.md).
+- **Context typing is by convention.** If the parent's middleware `c.set`s
+  `identity`, the sub-app's handlers read it because both declare the same
+  `Variables` type. TypeScript won't stop you mounting a sub-app that assumes
+  variables no middleware sets — a mount-site comment ("identity + repo already
+  set by the /api/* middleware") is the cheap guard, and an integration test
+  through the real parent app is the real one (testing.md).
+
+## `basePath`
+
+```typescript
+const api = new Hono().basePath('/api');
+api.get('/health', …);   // matches /api/health
+```
+
+`basePath` bakes the prefix into the app itself; `app.route(prefix, sub)` keeps
+the sub-app relocatable. Prefer `route()` for feature composition; use
+`basePath` when an entire deployment is served under a prefix (e.g. behind a
+gateway that doesn't strip it).
+
+## One composition-root file
+
+Keep every `app.use` / `app.route` / fallback / `onError` registration in one
+root file (`src/index.ts`), ordered top-to-bottom as the request flows:
+
+1. Global outbound middleware (security headers)
+2. Unauthenticated exceptions (health)
+3. Auth middleware for the protected pattern
+4. Protected routes + sub-app mounts
+5. Alternate-auth mounts (bearer sub-apps) outside the pattern
+6. JSON 404 for the API pattern
+7. SPA/asset catch-all — always last
+8. `app.onError`
+
+A reader (or `route-inventory.py`) can then audit the entire security topology
+by reading one file in order. Scattering `app.use` calls across feature files
+destroys that property — sub-apps may register their *own* interior middleware,
+but boundary middleware belongs to the root.
+
+## Growing to "many apps in one Worker"
+
+The scale pattern (from a production 7-app Worker):
+
+- Each feature = one exported sub-app in its own file/directory
+  (`src/time/api.ts`, `src/pulse-api.ts`), typed with the shared `Env`/`Vars`.
+- Feature-specific gates (e.g. an app-enabled check, an extra role gate) are
+  registered as `app.use('/api/pulse/*', gate)` in the root, directly above that
+  mount — visible in the composition root, not hidden in the feature file.
+- Sub-apps double-enforce their own authorization (a role check inside the
+  sub-app AND the data layer scoping) — mounts move, defence-in-depth survives.

+ 140 - 0
skills/hono-ops/references/errors-validation.md

@@ -0,0 +1,140 @@
+# Errors and Validation — onError, Typed Errors, the 404 Split, Boundary Validation
+
+One error boundary, typed error classes, a deliberate 404 strategy, and the
+zValidator-vs-hand-rolled decision for request validation.
+
+## Typed error classes → one `onError` mapping
+
+Domain code (data layer, integrations) throws typed errors; the HTTP layer maps
+them in exactly one place. Handlers stay thin and no layer needs to know HTTP.
+
+```typescript
+// errors.ts — the app's error vocabulary
+export class AppError extends Error {
+  constructor(public readonly status: number, public readonly code: string, message: string) {
+    super(message); this.name = 'AppError';
+  }
+}
+export const NotFound   = (m = 'not found')   => new AppError(404, 'not_found', m);
+export const Forbidden  = (m = 'forbidden')   => new AppError(403, 'forbidden', m);
+export const BadRequest = (m = 'bad request') => new AppError(400, 'bad_request', m);
+export const Conflict   = (m = 'version conflict, reload and retry') => new AppError(409, 'conflict', m);
+```
+
+Design notes:
+
+- **`code` is the machine field, `message` the human one.** Clients branch on
+  `code`; never make them parse prose.
+- **Mint a distinct code when the client's next action differs.** A retryable
+  409 ("reload and retry") and a non-retryable 409 ("this needs manual
+  reconciliation") deserve different codes even at the same status — the code
+  tells the caller *what to do*, the status tells proxies what happened.
+- **Factory functions with default messages** (`NotFound()`) keep call sites
+  one-word cheap, which is what makes people actually throw typed errors.
+- **Cross-scope reads throw NotFound, not Forbidden.** A 403 on someone else's
+  row confirms it exists; 404 doesn't leak existence.
+
+```typescript
+app.onError((err, c) => {
+  if (err instanceof AppError)       return c.json({ error: err.code, message: err.message }, err.status as 400);
+  if (err instanceof AuthError)      return c.json({ error: 'forbidden' }, 403);
+  if (err instanceof SyntaxError)    return c.json({ error: 'bad_request', message: 'invalid JSON body' }, 400);
+  if (err instanceof UpstreamError && err.code === 'not_configured') {
+    return c.json({ error: 'not_configured', message: err.message }, 503);
+  }
+  console.error('unhandled error', err);     // full detail to logs
+  return c.json({ error: 'internal' }, 500); // generic to the wire — never leak stack/message
+});
+```
+
+- The `err.status as 400` cast satisfies Hono's `StatusCode`-literal typing when
+  status is a runtime number; the class constructor is the real guard.
+- `SyntaxError` is what an unhandled `await c.req.json()` throws on a malformed
+  body — mapping it here turns garbage bodies into a clean 400 for every route
+  that didn't bother to `.catch`.
+- Map upstream/integration error types by *their* codes to statuses that tell the
+  truth: `not_configured` → 503, upstream validation refusal → 422, upstream
+  rate-limit → 429, upstream broke → 502.
+- Hono also has `HTTPException` (`hono/http-exception`); its `onError` case is
+  `err instanceof HTTPException ? err.getResponse() : …`. Prefer your own
+  `AppError` vocabulary for domain errors — `HTTPException` couples domain code
+  to HTTP and carries no machine `code` field.
+
+## The 404 split: JSON for the API, shell for the SPA
+
+With a SPA served from the same Worker, "not found" means two different things:
+
+```typescript
+// After all real routes/mounts:
+app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));   // API typo → JSON 404
+app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));              // anything else → SPA
+```
+
+- Without the explicit `/api/*` 404, a fat-fingered API path falls through to the
+  SPA catch-all and returns `index.html` with a 200 — the client then fails on
+  `res.json()` three layers away from the actual bug.
+- `app.notFound(handler)` only fires when nothing matched; a `*` catch-all means
+  nothing is ever unmatched, so it's dead code in this topology. Use the explicit
+  route pair.
+- Order: the `/api/*` 404 goes after every API mount, and the `*` catch-all is
+  the last route in the file.
+
+## Validation at the HTTP boundary
+
+Two viable approaches; pick per-app, not per-route (consistency is a feature).
+
+### Schema middleware: `@hono/zod-validator`
+
+```typescript
+import { zValidator } from '@hono/zod-validator';
+import { z } from 'zod';
+
+const CreateUser = z.object({ email: z.email(), role: z.enum(['admin', 'user']) });
+
+app.post('/api/users', zValidator('json', CreateUser), async (c) => {
+  const body = c.req.valid('json');   // fully typed, already validated
+  …
+});
+```
+
+- Targets: `json`, `query`, `param`, `header`, `form`, `cookie`.
+- Invalid input → automatic 400 with Zod's error structure; customise the
+  response shape with the third `(result, c) => …` hook argument — do this
+  once in a wrapped helper so your error envelope (`{ error, message }`) stays
+  consistent with `onError`'s.
+- `c.req.valid('json')` is the *only* typed accessor; `await c.req.json()` in
+  the same handler bypasses validation entirely.
+- Valibot/ArkType/effect equivalents exist (`@hono/valibot-validator`, …) —
+  same shape; valibot's tree-shaken bundle is materially smaller, which matters
+  at Workers' bundle-size limits.
+
+### Hand-rolled: tolerant parse + explicit assertions
+
+```typescript
+// Parse failure degrades to {} — the explicit checks below produce the 400s.
+const body = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string });
+if (!body.email) return c.json({ error: 'bad_request', message: 'email is required' }, 400);
+
+// Shared assertion helpers for recurring shapes:
+const date = assertDateString(body.date, 'date');   // throws BadRequest('date must be YYYY-MM-DD')
+```
+
+The `.catch(() => ({}))` idiom means a malformed body and a missing field take
+the same, deliberate 400 path (with your envelope), rather than a `SyntaxError`
+surfacing through `onError`.
+
+### Trade-offs
+
+| | zValidator (schema middleware) | Hand-rolled assertions |
+|---|---|---|
+| Types | Inferred from schema — payload type and validation can't drift | `c.req.json<T>()` is a **cast, not a check** — T drifts from reality silently |
+| Error shape | Zod's, unless you customise the hook everywhere | Yours by construction, consistent with `onError` |
+| Deps / bundle | zod (or valibot) in the Worker bundle | Zero |
+| Cross-field / DB-dependent rules | Awkward — lands in the handler anyway | Same place as everything else |
+| RPC | Required — `hc` derives input types from validators (rpc-clients.md) | No input typing on the client |
+| Best for | Broad CRUD surfaces, RPC apps, teams | Small/hot Workers, apps whose real invariants are enforced in the data layer |
+
+The honest middle: schema-validate the *shape* at the boundary, keep *business*
+invariants (version checks, scoping, state-machine rules) in the domain layer
+throwing typed errors. Never let a schema pass for authorization — identity
+comes from verified credentials (middleware.md), not from a validated body.

+ 170 - 0
skills/hono-ops/references/middleware.md

@@ -0,0 +1,170 @@
+# Middleware — Ordering, Auth Boundaries, Response Hardening
+
+The middleware layer is where a Hono app's security topology lives. This file
+covers the execution model, the auth-middleware pattern that makes handlers
+safe by construction, and the two boundary patterns (skip-lists and
+outside-the-pattern mounts).
+
+## The execution model (onion + registration order)
+
+```typescript
+app.use('*', async (c, next) => {
+  // inbound: runs before any matching handler
+  await next();
+  // outbound: runs after the handler (and after inner middleware) — c.res is set
+});
+```
+
+- **Registration order is matching order.** For a request, Hono runs every
+  middleware whose path pattern matches, in the order registered, then the
+  handler. A middleware registered *after* a matching handler does not run for
+  it. This is the single most common Hono bug; `route-inventory.py --check`
+  (this skill) flags it statically.
+- **Returning without `await next()` short-circuits** — that's how auth rejects
+  (`return c.json({ error: 'forbidden' }, 403)`).
+- **Outbound code sees `c.res`** and may replace it (`c.res = new Response(...)`).
+- `try { await next(); } finally { … }` guarantees outbound bookkeeping runs
+  even when a handler throws (e.g. persisting a session cookie regardless of
+  outcome). Note `onError` produces the response *after* your `finally` runs.
+
+## The auth middleware pattern: verify, then build the request's world
+
+Verify credentials once, then stash everything downstream code needs — identity
+AND pre-scoped resources — so handlers physically can't do unscoped work:
+
+```typescript
+app.use('/api/*', async (c, next) => {
+  if (c.req.path === '/api/health') return next();   // skip-list (see below)
+
+  // 1. Resolve the tenant/context from the request (host, header…)
+  const tenant = await findTenantByHost(c.env.DB, new URL(c.req.url).host.toLowerCase());
+  if (!tenant?.active) return c.json({ error: 'unknown tenant' }, 404);
+
+  // 2. Verify the credential. Verification failures are typed and mapped to 403 —
+  //    never a 500, never a passthrough.
+  let user: UserRow | null;
+  try {
+    user = await resolveCaller(c.req.raw, c.env, tenant);   // JWT verify + user lookup
+  } catch (err) {
+    if (err instanceof AuthError) return c.json({ error: 'forbidden' }, 403);
+    throw err;
+  }
+  if (!user) return c.json({ error: 'no access for this user' }, 403);
+
+  // 3. Build the verified world into context. Handlers read c.get(...) only.
+  c.set('identity', { userId: user.id, tenantId: tenant.id, role: user.role });
+  c.set('repo', createScopedRepository(c.env.DB, c.get('identity')));
+  await next();
+});
+```
+
+Why this shape wins:
+
+- **Identity comes from the verified credential, never the request body.** No
+  handler ever reads a `tenantId` out of JSON.
+- **Handlers get a scoped data layer, not raw bindings.** A handler that only
+  has `c.get('repo')` cannot query another tenant even by bug — the scoping
+  argument was bound before the handler existed.
+- **One place to extend.** Impersonation, read-replica session selection, and
+  audit stamping all layer into this middleware without touching handlers.
+
+### JWT verification specifics
+
+Verify signature + issuer + audience, never just decode. With `jose`,
+`createRemoteJWKSet` caches the JWKS per isolate and refetches on unknown `kid`,
+so key rotation doesn't cause spurious 403s. Cache the JWKS instance in a
+module-level `Map` keyed by issuer — module scope survives across requests in a
+Workers isolate.
+
+## Boundary pattern 1: skip-lists (exceptions inside the pattern)
+
+One or two public routes inside an otherwise-protected pattern: register the
+route before the middleware AND skip it inside (belt + braces — order protects
+it today, the skip-list documents intent and survives reordering):
+
+```typescript
+app.get('/api/health', (c) => c.json({ ok: true }));       // before auth
+app.use('/api/*', async (c, next) => {
+  if (c.req.path === '/api/health') return next();          // explicit exception
+  …
+});
+```
+
+Use a skip-list for a *handful* of exact paths. The moment you're pattern-matching
+exceptions (`startsWith`, regex), you want pattern 2 instead.
+
+## Boundary pattern 2: mount OUTSIDE the pattern (different auth model)
+
+Machine-to-machine endpoints (a bearer-key read API, an ingest webhook) must not
+inherit interactive session auth. Don't exempt them from the session middleware —
+mount them on a path the middleware pattern doesn't cover, with their own auth:
+
+```typescript
+// Sub-app with its own bearer auth (own file):
+export const vesper = new Hono<{ Bindings: VesperEnv; Variables: VesperVars }>();
+vesper.use('*', async (c, next) => {
+  const auth = c.req.header('authorization') ?? '';
+  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
+  const keys = (c.env.VESPER_KEYS ?? '').split(',').map((k) => k.trim()).filter(Boolean);
+  if (!(token.length > 0 && keys.some((k) => timingSafeEqual(token, k)))) {
+    return c.json({ error: 'unauthorized' }, 401);
+  }
+  // build this surface's own (narrow) context, then:
+  await next();
+});
+
+// Root: /vesper and /ingest are NOT under /api/*, so session auth never sees them.
+app.route('/vesper', vesper);
+app.route('/ingest', ingest);
+```
+
+Operational notes for this pattern:
+
+- **Accept two comma-separated keys** so rotation is zero-downtime: add the new
+  key, roll clients, remove the old.
+- **Compare bearer keys in constant time** over a fixed length (XOR-accumulate
+  across `max(len(a), len(b))`, fold in the length difference) — an early-return
+  string compare leaks prefix length via timing.
+- If the Worker sits behind an edge access product (e.g. Cloudflare Access),
+  these paths need an explicit bypass/service-auth policy at the edge too — the
+  bearer key is the real gate, but the edge must let the request through.
+- Give the bearer sub-app its own narrow `Env`/`Vars` types: it needs the DB and
+  its keys, not the whole interactive surface.
+
+## Response-hardening middleware (and the immutable-headers trap)
+
+Global outbound middleware that fills in missing security headers:
+
+```typescript
+export function securityHeaders(): MiddlewareHandler {
+  return async (c, next) => {
+    await next();
+    // Responses derived from fetch()/ASSETS.fetch have IMMUTABLE headers in the
+    // Workers runtime — mutating them in place throws and 500s every page load.
+    // Copy into a fresh Headers and hand back a new Response.
+    const headers = new Headers(c.res.headers);
+    const setIfMissing = (n: string, v: string) => { if (!headers.has(n)) headers.set(n, v); };
+    setIfMissing('x-content-type-options', 'nosniff');
+    setIfMissing('x-frame-options', 'DENY');
+    setIfMissing('referrer-policy', 'no-referrer');
+    setIfMissing('strict-transport-security', 'max-age=31536000; includeSubDomains');
+    c.res = new Response(c.res.body, { status: c.res.status, statusText: c.res.statusText, headers });
+  };
+}
+app.use('*', securityHeaders());   // registered FIRST = outermost = sees every response
+```
+
+- `setIfMissing` (not `set`) preserves route-owned headers — the layer fills
+  gaps, it doesn't override decisions.
+- Register it first so the SPA fallback's responses pass through it too.
+- Roll out CSP as `content-security-policy-report-only` first; enforce after the
+  report stream is quiet.
+
+## Built-in middleware worth knowing
+
+Hono ships `hono/cors`, `hono/logger`, `hono/secure-headers`, `hono/etag`,
+`hono/compress` (not useful on Workers — the platform compresses),
+`hono/bearer-auth`, `hono/jwt`, `hono/cookie` (helpers: `getCookie`/`setCookie`/
+`deleteCookie`). Use them for the generic 80%; write your own (as above) when
+the behaviour is a product decision (which headers, which auth failure shape) —
+a 20-line middleware you fully own beats configuring around a generic one.

+ 117 - 0
skills/hono-ops/references/routing-and-request.md

@@ -0,0 +1,117 @@
+# Routing and the Request/Response Surface
+
+Router internals, path syntax, matching precedence, and the `c.req` / response
+helper surface — the mechanics under every route you write.
+
+## Routers (what Hono picks and why you care)
+
+Hono selects a router automatically (`SmartRouter`):
+
+| Router | Character | When it's used |
+|---|---|---|
+| `RegExpRouter` | Compiles ALL routes into one regex — fastest match | Default when the route set allows it |
+| `TrieRouter` | General tree walk — supports everything | Fallback for patterns RegExpRouter can't compile |
+| `LinearRouter` / `PatternRouter` | Fast-register, small | `hono/quick` / `hono/tiny` presets for one-shot environments |
+
+Practical consequences:
+
+- On Workers, routes register per isolate boot; matching happens every request.
+  The defaults are right — don't hand-pick a router without a measured reason.
+- `hono/tiny` (`PatternRouter`) cuts bundle size when you're near the Workers
+  compressed-size limit and have few routes.
+
+## Path syntax
+
+```typescript
+app.get('/users/:id', …);                 // named param        c.req.param('id')
+app.get('/users/:id/posts/:postId', …);   // multiple params    c.req.param() -> object
+app.get('/files/:name{.+\\.png}', …);     // regex-constrained param
+app.get('/posts/:date{[0-9]+}/:title', …);// digits-only param
+app.get('/api/*', …);                     // wildcard (any depth)
+app.get('/about/:lang?', …);              // optional param: /about and /about/en
+app.on('PURGE', '/cache', …);             // custom method
+app.on(['PUT', 'DELETE'], '/thing', …);   // several methods, one handler
+```
+
+Matching rules that surprise people:
+
+- **Registration order wins among equally-matching routes** — the first
+  registered match handles the request. `app.get('/*', …)` registered early
+  shadows everything after it (handlers don't fall through like middleware).
+- A handler matches its exact pattern only; middleware (`app.use`) matches by
+  prefix pattern. `app.get('/api')` does not match `/api/`.
+- Params are URL-decoded; a `:param` never matches across `/`.
+
+## `c.req` — the request surface
+
+| Accessor | Returns | Notes |
+|---|---|---|
+| `c.req.param('id')` | `string` | Route params; `c.req.param()` for all as an object |
+| `c.req.query('q')` | `string \| undefined` | First value; `c.req.query()` for all |
+| `c.req.queries('tag')` | `string[] \| undefined` | Repeated keys (`?tag=a&tag=b`) |
+| `c.req.header('x-foo')` | `string \| undefined` | Case-insensitive |
+| `await c.req.json<T>()` | `T` | **`T` is a cast, not a check** — validate (errors-validation.md) |
+| `await c.req.text()` / `.arrayBuffer()` / `.blob()` | body | Raw body reads — body is consumable once |
+| `await c.req.parseBody()` | form fields | `multipart/form-data` + urlencoded; files as `File` |
+| `c.req.valid('json')` | validated type | Only after validator middleware |
+| `c.req.raw` | `Request` | The real Request — pass to `ASSETS.fetch`, JWT verifiers, anything platform-level |
+| `c.req.path` / `c.req.url` / `c.req.method` | strings | `path` excludes query; `url` is absolute |
+
+- **Uploads:** for raw-body uploads read `c.req.raw.body` (a stream) and hand it
+  straight to R2 (`bucket.put(key, body)`) — don't buffer whole files through
+  `arrayBuffer()` unless you must enforce a byte cap by inspection. Enforce
+  content-type against an **allowlist** and cap size before writing.
+- The body is a one-shot stream: reading it twice throws. If middleware must
+  inspect the body, `c.req.raw.clone()` — and know that clones buffer.
+
+## Responses
+
+| Helper | Produces |
+|---|---|
+| `c.json(obj, status?)` | `application/json`; status defaults 200 |
+| `c.text(s)` / `c.html(s)` | text/plain, text/html |
+| `c.body(data, status, headers?)` | raw body — `c.body(null, 204)` for no-content |
+| `c.redirect(url, status?)` | 302 default |
+| `c.notFound()` | delegates to `app.notFound` handler |
+| `new Response(...)` returned directly | fully manual — Hono passes it through |
+
+- Status codes are typed literals (`StatusCode`); a runtime number needs a cast
+  (`status as 400`) — accept the cast at the single onError mapping site, not
+  scattered through handlers.
+- Set response headers with `c.header('x-foo', 'bar')` *before* returning the
+  helper, or build a manual `Response`.
+- `c.json` serialises with plain `JSON.stringify` — `Date` becomes an ISO
+  string, `undefined` fields vanish, `BigInt` throws. Shape rows through a
+  presenter function first (one place deciding what leaves the Worker per role,
+  rather than serialising DB rows raw).
+
+## Cookies
+
+```typescript
+import { getCookie, setCookie, deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
+
+setCookie(c, 'session_hint', value, {
+  httpOnly: true, secure: true, sameSite: 'Strict', path: '/', maxAge: 60 * 60,
+});
+const v = getCookie(c, 'session_hint');
+deleteCookie(c, 'session_hint', { path: '/' });   // path must match the set
+```
+
+- Default to `httpOnly + secure + sameSite: 'Strict'`; loosen deliberately.
+- `deleteCookie` must repeat the `path` (and `domain`) used at set time or the
+  browser keeps the original.
+- **A cookie the client can write is a hint, not a fact.** Re-verify authority
+  server-side on every request (e.g. an impersonation cookie only takes effect
+  when the *verified* identity is an admin — a forged cookie is then inert).
+  Signed cookies (`setSignedCookie` with a secret) make tampering detectable,
+  but signing doesn't replace the authority check: sign what you must trust
+  client-side, re-verify what the server can decide itself.
+
+## HTML / JSX
+
+`hono/jsx` renders server-side JSX (`c.html(<Page/>)`) with zero client
+runtime — fine for small server-rendered pages and emails from the same Worker.
+For an actual SPA, build it separately and serve via the assets binding
+(workers-runtime.md); don't grow a JSX app inside an API Worker past a page or
+two. `hono/html` offers a `html` template literal with auto-escaping for
+one-off snippets — never string-concatenate HTML with user input.

+ 130 - 0
skills/hono-ops/references/rpc-clients.md

@@ -0,0 +1,130 @@
+# RPC and Typed Clients — hc vs Hand-Rolled
+
+Hono ships an end-to-end typed client (`hc`). It is excellent for the apps it
+fits and quietly costly for the ones it doesn't. This file covers how it works,
+the inference rules that bite, and when a hand-rolled typed client is the
+better engineering call.
+
+## The RPC mechanism
+
+Server: export the *type* of your routes. Client: `hc<AppType>` derives a typed
+call surface from it — paths, params, validated inputs, and JSON output types.
+
+```typescript
+// server.ts
+import { Hono } from 'hono';
+import { zValidator } from '@hono/zod-validator';
+import { z } from 'zod';
+
+// CHAINED definition — the inference requirement (see below)
+const app = new Hono()
+  .get('/posts/:id', (c) => c.json({ post: { id: c.req.param('id'), title: 't' } }))
+  .post('/posts', zValidator('json', z.object({ title: z.string() })), (c) => {
+    const body = c.req.valid('json');
+    return c.json({ ok: true, title: body.title }, 201);
+  });
+
+export type AppType = typeof app;   // types only cross the boundary
+export default app;
+```
+
+```typescript
+// client.ts
+import { hc } from 'hono/client';
+import type { AppType } from './server';
+
+const client = hc<AppType>('https://api.example.com');
+
+const res = await client.posts[':id'].$get({ param: { id: '123' } });
+if (res.ok) {
+  const data = await res.json();   // typed: { post: { id: string, title: string } }
+}
+await client.posts.$post({ json: { title: 'hello' } });   // input typed from the validator
+```
+
+Key mechanics:
+
+- **Input types come from validator middleware.** No `zValidator` (or peer) on a
+  route → `$post({ json })` is untyped. RPC and schema validation are a package
+  deal (errors-validation.md).
+- **Output types come from `c.json(...)` inference**, per status code.
+- `res` is a real `Response` — check `res.ok`/status before `.json()`.
+- `hc` accepts a custom `fetch` (pass a Service Binding's fetcher for
+  Worker-to-Worker calls, or a test app's `app.request`).
+
+## The inference rules that bite
+
+1. **Routes must be CHAINED for inference.** `const app = new Hono().get(...).post(...)`
+   captures route types in `typeof app`; separate `app.get(...)` statements
+   return types that are never accumulated. A file refactor from chained to
+   statement style silently degrades the client to `unknown` — guard the shape
+   with a comment at the definition site.
+2. **Sub-apps compose via chained `.route()`:**
+   `const routes = app.route('/posts', posts).route('/users', users);
+   export type AppType = typeof routes;` — same chaining rule, one exported type.
+3. **Compile-time cost grows with the surface.** Tens of routes with inferred
+   unions can make tsc/editor latency real. Mitigations: split clients per
+   sub-app (`hc<typeof postsApp>`), or precompute the client type once
+   (`type Client = ReturnType<typeof hc<AppType>>`) and reuse it.
+4. **The client imports server types** — the client build must resolve the
+   server's TypeScript (monorepo path aliases, project references, or a
+   published types package). Type-only imports (`import type`) keep server
+   *code* out of the client bundle, but the *type graph* still has to compile
+   in the client's tsconfig.
+
+## When a hand-rolled typed client is the better call
+
+`hc` optimizes for "the server's inferred types ARE the contract." That's wrong
+for some real apps:
+
+- **The wire contract is curated, not inferred.** When responses pass through a
+  serialization boundary (presenters that strip admin-only fields per role), the
+  honest client type is the *presented* shape, which inference can't see —
+  `c.json(present(row, isAdmin))` infers the union, not the per-role reality.
+- **No validator middleware** (hand-rolled validation) → no input typing from
+  `hc` anyway, which removes half its value.
+- **Statement-style route registration** across a large composition root
+  (middleware boundaries, conditional mounts) — restructuring 100+ routes into
+  chained style to please inference is the tail wagging the dog.
+- **Client and server deliberately decoupled** (separate repos/builds, or a
+  public API where the contract is versioned prose/OpenAPI, not your source).
+
+The hand-rolled pattern that scales:
+
+```typescript
+// web/src/api/types.ts — the wire contract, stated explicitly (shared file or
+// copied deliberately; drift is caught by wire-level tests, not the compiler)
+export interface Commission { id: string; period: string; status: CommissionStatus; … }
+
+// web/src/api/client.ts — one tiny fetch wrapper + named functions
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const res = await fetch(`/api${path}`, { headers: { 'content-type': 'application/json' }, ...init });
+  if (!res.ok) throw await toApiError(res);   // parse { error, message } envelope
+  return res.json() as Promise<T>;
+}
+
+export const getCommissions = (q?: { period?: string }) =>
+  request<{ commissions: Commission[] }>(`/commissions${qs(q)}`);
+export const settleCommission = (id: string, body: SettleInput) =>
+  request<{ commission: Commission }>(`/commissions/${id}/settle`, { method: 'POST', body: JSON.stringify(body) });
+```
+
+Pair it with **wire-level contract tests** on the server (assert the exact field
+set a non-privileged role receives from the real route) — that's the drift
+tripwire the compiler was providing, moved to where the curated contract
+actually lives.
+
+## Decision table
+
+| Signal | Use `hc` RPC | Hand-roll |
+|---|---|---|
+| Validation | zValidator/peer on every route | Hand-rolled assertions |
+| Route style | Chained (or willing to be) | Statement-style composition root |
+| Response shaping | `c.json` output IS the contract | Presenter/role-based field stripping |
+| Repo layout | Monorepo, shared tsconfig | Separate builds/repos, versioned contract |
+| Surface size | Small–medium, or split per sub-app | Very large, latency-sensitive tsc |
+| Consumers | Your own TS frontend | Multiple/external/non-TS consumers |
+
+Middle path: use `hc` for an *internal* sub-app that fits (chained, validated),
+hand-roll the curated public surface. Nothing forces one client for the whole
+Worker.

+ 133 - 0
skills/hono-ops/references/streaming-and-realtime.md

@@ -0,0 +1,133 @@
+# Streaming, SSE, WebSockets, and Worker-to-Worker Calls
+
+Long-lived and incremental responses from a Hono Worker: streamed bodies,
+server-sent events, WebSockets (and when a Durable Object must own them), plus
+proxying and service-binding calls.
+
+## Streamed responses (`hono/streaming`)
+
+```typescript
+import { stream, streamText, streamSSE } from 'hono/streaming';
+
+// Raw bytes — e.g. piping a generated file without buffering it
+app.get('/export.csv', (c) =>
+  stream(c, async (s) => {
+    await s.write(header);
+    for await (const row of rows()) await s.write(encode(row));
+  }),
+);
+
+// Incremental text (LLM token relays, progress logs)
+app.post('/api/generate', (c) =>
+  streamText(c, async (s) => {
+    for await (const chunk of model.generate(prompt)) await s.write(chunk);
+  }),
+);
+```
+
+- The handler returns immediately; the callback keeps writing on the open body.
+  Errors mid-stream can't change the status line (it's already sent) — write an
+  in-band error sentinel the client understands, and pass an `onError` third
+  argument to close cleanly.
+- `s.writeln`, `s.sleep`, `s.close`, and `c.req.raw.signal.aborted` /
+  `s.onAbort(cb)` cover pacing and client-disconnect cleanup. Check abort in
+  long loops — writing to a gone client is wasted CPU time.
+- Workers streams responses natively; there's no buffering to disable, but the
+  invocation is still bounded by Workers CPU/duration limits — streaming is for
+  minutes at most, not persistent connections (that's WebSockets/DO territory).
+
+## Server-sent events
+
+```typescript
+app.get('/api/events', (c) =>
+  streamSSE(c, async (s) => {
+    let id = 0;
+    while (!c.req.raw.signal.aborted) {
+      const events = await pollSource(c.env);          // or a queue/DO handoff
+      for (const e of events) {
+        await s.writeSSE({ data: JSON.stringify(e), event: e.type, id: String(++id) });
+      }
+      await s.sleep(5000);
+    }
+  }),
+);
+```
+
+- SSE through a plain Worker is a **poll relay** — each connected client holds
+  an invocation open. Fine for admin dashboards (few clients); wrong for fanning
+  out to thousands (that's a Durable Object with hibernatable WebSockets, or a
+  push service).
+- Send a retry hint (`s.writeSSE({ data: '', event: 'ping' })` heartbeats every
+  ~30s) so intermediaries don't reap the idle connection.
+- `EventSource` can't set headers — cookie auth works, bearer auth doesn't;
+  for token auth use a query-string ticket minted by an authenticated call
+  (short-lived, single-use), not the long-lived token in the URL.
+
+## WebSockets
+
+Plain Worker upgrade (stateless per-socket, no cross-socket coordination):
+
+```typescript
+import { upgradeWebSocket } from 'hono/cloudflare-workers';
+
+app.get('/ws', upgradeWebSocket((c) => ({
+  onMessage(evt, ws) { ws.send(`echo ${evt.data}`); },
+  onClose() {},
+})));
+```
+
+Reality check before shipping that:
+
+- A Worker-held socket ties an invocation to the connection and cannot share
+  state with other sockets. **Any feature described as "broadcast", "room",
+  "presence", or "sync" is a Durable Object feature**: route the upgrade to a
+  DO (`c.env.ROOM.get(id).fetch(c.req.raw)`) and use the DO WebSocket API —
+  with hibernation (`state.acceptWebSocket(ws)` + `webSocketMessage` handlers)
+  so idle sockets don't bill wall-clock duration.
+- Auth happens at upgrade time (it's a GET through your normal middleware);
+  after upgrade there is no per-message auth — bind identity to the socket at
+  accept and treat the connection as a session.
+- The `upgradeWebSocket` import is per-runtime (`hono/cloudflare-workers`,
+  `hono/deno`, `hono/bun`) — the one non-portable seam in an otherwise portable
+  app.
+
+## Proxying and Worker-to-Worker (service bindings)
+
+```typescript
+// Pass-through proxy of an upstream (rewrite path, forward body/headers):
+app.all('/upstream/*', (c) => {
+  const url = new URL(c.req.url);
+  url.hostname = 'internal.example.com';
+  url.pathname = url.pathname.replace(/^\/upstream/, '');
+  // New Request from the original: method/headers/body carry over; mutate a COPY
+  // of headers (the original's are immutable).
+  const headers = new Headers(c.req.raw.headers);
+  headers.delete('cookie');                       // never leak session cookies upstream
+  return fetch(new Request(url, { method: c.req.method, headers, body: c.req.raw.body }));
+});
+
+// Service binding: call another Worker with zero network hop
+interface Env { REPORTS: Fetcher }                // [[services]] binding in wrangler config
+app.get('/api/report', (c) => c.env.REPORTS.fetch(c.req.raw));
+```
+
+- A returned upstream `Response` is streamed through — no buffering — but its
+  headers are immutable; rebuild if you must edit (middleware.md).
+- Service bindings invoke the target Worker directly (same thread, no egress):
+  prefer them over public-URL `fetch` between your own Workers — faster, free of
+  DNS/TLS, and the target can trust the caller. An `hc` RPC client accepts a
+  binding's fetcher: `hc<AppType>('https://internal', { fetch: c.env.REPORTS.fetch.bind(c.env.REPORTS) })`
+  — note the `.bind()`: an unbound method reference throws "Illegal invocation"
+  (workers-runtime.md).
+- Forwarding `c.req.raw.body` consumes it — a proxy handler can't also read the
+  body; decide per route.
+
+## Choosing the mechanism
+
+| Need | Use |
+|---|---|
+| Incremental one-shot response (LLM tokens, big export) | `stream` / `streamText` |
+| Server→client event feed, few clients, reconnect-tolerant | `streamSSE` (+ heartbeat) |
+| Bidirectional, or many clients, or shared room state | WebSockets **in a Durable Object** (hibernation) |
+| Client polling an expensive read | Plain GET + per-colo `caches` collapse (workers-runtime.md) |
+| Worker calling your other Worker | Service binding, not public fetch |

+ 176 - 0
skills/hono-ops/references/testing.md

@@ -0,0 +1,176 @@
+# Testing — app.request, vitest-pool-workers, Middleware Isolation
+
+Hono apps are directly invokable — no server, no port. This file covers unit
+calls, the full vitest-pool-workers setup (real bindings inside workerd), an
+auth-harness pattern for JWT-protected apps, and testing middleware alone.
+
+## `app.request()` / `app.fetch()` — the unit seam
+
+```typescript
+// Simple: path + RequestInit + env (the Bindings object)
+const res = await app.request('/api/health', {}, env);
+expect(res.status).toBe(200);
+await expect(res.json()).resolves.toEqual({ ok: true });
+
+// Full control (method, headers, host — needed when auth branches on hostname):
+const res2 = await app.fetch(
+  new Request('https://app.example.com/api/me', {
+    method: 'GET',
+    headers: { 'authorization': `Bearer ${key}` },
+  }),
+  env,
+);
+```
+
+- The third argument is `c.env` — pass real bindings (pool-workers) or a
+  hand-built stub for pure-logic tests.
+- `app.fetch(new Request(...))` whenever the URL matters: host-based tenancy,
+  absolute-URL parsing, cookies (set a `cookie` header).
+- These run the *entire* pipeline — middleware, routing, `onError` — so a test
+  asserting a 403 is testing the real boundary, not a mock of it.
+- Sub-apps are apps: `timeApi.request('/entries', {}, env)` exercises a feature
+  app mount-relative, without the parent's middleware (useful for isolating
+  behaviour; not a substitute for at least some through-the-parent tests, since
+  the parent's middleware sets the context the sub-app assumes).
+
+## vitest-pool-workers: real bindings inside workerd
+
+`@cloudflare/vitest-pool-workers` runs the test file *inside* the Workers
+runtime, with real D1/KV/R2/DO bindings.
+
+```typescript
+// vitest.config.ts
+import { defineWorkersConfig, readD1Migrations } from '@cloudflare/vitest-pool-workers/config';
+
+export default defineWorkersConfig(async () => {
+  const migrations = await readD1Migrations('./migrations');
+  return {
+    test: {
+      include: ['test/**/*.test.ts'],
+      // Exclude sibling worktrees: .claude/worktrees/* carry their own stale copy
+      // of test/ + migrations/ and would double-count / fail this run.
+      exclude: ['**/node_modules/**', '**/.claude/**', 'web/**'],
+      setupFiles: ['./test/apply-migrations.ts'],
+      poolOptions: {
+        workers: {
+          singleWorker: true,        // one workerd for the suite (faster, shared module state)
+          isolatedStorage: true,     // per-TEST-FILE storage; writes don't leak across files
+          miniflare: {
+            compatibilityDate: '2024-12-01',
+            compatibilityFlags: ['nodejs_compat'],
+            d1Databases: { DB: 'my-app-test' },
+            bindings: {
+              TEST_MIGRATIONS: migrations,      // handed to the setup file
+              SOME_CONFIG_VAR: 'test-value',    // plain-var bindings for the suite
+            },
+          },
+        },
+      },
+    },
+  };
+});
+```
+
+```typescript
+// test/apply-migrations.ts — run the REAL migrations so tests hit the same
+// schema (and CHECK constraints) as production.
+import { applyD1Migrations, env } from 'cloudflare:test';
+await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
+```
+
+```typescript
+// In tests: `env` is the typed binding set from the config above.
+import { env } from 'cloudflare:test';
+import { app } from '../src/index';
+
+const res = await app.request('/api/things', {}, { ...env, EXTRA_VAR: 'per-suite override' });
+```
+
+Notes:
+
+- Type `env` by declaring `interface ProvidedEnv extends Env {}` in a
+  `test/env.d.ts` (`declare module 'cloudflare:test'`).
+- Spread-and-override (`{ ...env, KEY: 'x' }`) is the idiom for per-test env
+  variation — bindings are just an object at this seam.
+- `isolatedStorage` isolation is per test *file*; within a file, use
+  `beforeEach` re-seeding for a known DB state.
+- **workerd version lag:** the local workerd that pool-workers ships is pinned
+  by your `wrangler`/pool-workers package version and can trail (or lead) the
+  deployed runtime. Behaviour keyed to `compatibilityDate` matches; brand-new
+  runtime features/fixes may not. Keep the config's `compatibilityDate` equal to
+  wrangler config's, update the toolchain deliberately, and treat "passes local,
+  fails deployed" as a version-skew suspect.
+- Cron handlers: pool-workers can't fire real cron; call the export directly —
+  `worker.scheduled({ cron: '*/5 * * * *' } as ScheduledController, env, ctx)`
+  with a stub `ctx` collecting `waitUntil` promises you then `await`.
+
+## Auth harness: testing behind JWT verification
+
+For an app whose middleware verifies JWTs against a remote JWKS, generate a
+throwaway keypair in the suite and intercept the JWKS fetch:
+
+```typescript
+// test/access-harness.ts (pattern)
+import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose';
+
+// 1. beforeAll: generate an RS256 keypair (+ a mismatched "bad" key for
+//    negative tests) and build a JWKS from the public key.
+// 2. Patch globalThis.fetch: requests to the JWKS URL return the test JWKS;
+//    everything else passes through to the original fetch. Restore in afterAll.
+// 3. signAccessToken(email, { aud, issuer, expOffsetSec, badKey }): a SignJWT
+//    helper with correct defaults and overridable claims for negative cases.
+```
+
+The payoff is a **negative-auth matrix** against the real app: missing token,
+garbage token, wrong audience, wrong issuer, expired, wrong-key signature,
+valid-token-but-unknown-user — each asserted to 403 through `app.fetch`. These
+tests pin the security boundary at the wire, where it actually holds; a
+repo-layer test can pass while a route leaks.
+
+If the platform verifies for you in production (e.g. Cloudflare Access in front),
+your middleware must *still* verify — the harness proves it does.
+
+## Testing middleware in isolation
+
+Mount just the middleware on a throwaway app with a probe route:
+
+```typescript
+import { Hono } from 'hono';
+import { securityHeaders } from '../src/http/security-headers';
+
+function harness() {
+  const app = new Hono();
+  app.use('*', securityHeaders());
+  app.get('/probe', (c) => c.json({ ok: true }));
+  app.get('/custom', (c) => {
+    const res = c.json({ ok: true });
+    res.headers.set('x-frame-options', 'SAMEORIGIN');   // route-owned header
+    return res;
+  });
+  return app;
+}
+
+it('fills missing security headers', async () => {
+  const res = await harness().request('/probe');
+  expect(res.headers.get('x-content-type-options')).toBe('nosniff');
+});
+
+it('preserves route-owned headers', async () => {
+  const res = await harness().request('/custom');
+  expect(res.headers.get('x-frame-options')).toBe('SAMEORIGIN');
+});
+```
+
+This is the right level for ordering/onion behaviour (inbound vs outbound,
+short-circuits, header merging). Auth middleware is the exception: test it
+through the real composed app (above), because its job *is* the composition.
+
+## What to test at which level
+
+| Level | Seam | Use for |
+|---|---|---|
+| Pure function | direct call | presenters/serializers, error mapping helpers, validators |
+| Middleware harness | tiny Hono + probe routes | onion behaviour, header policy, bearer compare |
+| Sub-app | `subApp.request()` | feature routes with stubbed context/bindings |
+| Composed app | `app.fetch(new Request, env)` | auth matrix, 404 split, mount topology, wire-level field visibility |
+| Composed app + real bindings | pool-workers `env` | anything touching D1/KV/R2; migration-schema fidelity |

+ 154 - 0
skills/hono-ops/references/workers-runtime.md

@@ -0,0 +1,154 @@
+# Workers Runtime Integration — SPA Serving, Cron/Queues, Runtime Gotchas
+
+One Worker frequently serves an API, a SPA, cron jobs, and queue consumers.
+This file covers wiring all of them around one Hono app, plus the Workers
+runtime behaviours that bite Hono code specifically.
+
+## Serving a SPA + API from one Worker (static assets binding)
+
+```jsonc
+// wrangler.jsonc
+{
+  "assets": {
+    "directory": "./web/dist",                    // built SPA
+    "binding": "ASSETS",                          // exposes env.ASSETS (Fetcher)
+    "not_found_handling": "single-page-application",  // unknown paths -> index.html
+    "run_worker_first": ["/api/*", "/vesper/*", "/ingest/*"]  // Worker sees these BEFORE assets
+  }
+}
+```
+
+```typescript
+interface Env { ASSETS: Fetcher; /* … */ }
+
+// After all API routes:
+app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));  // JSON 404, never the shell
+app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));             // hand everything else to assets
+```
+
+- Without `run_worker_first`, requests matching an asset path are served
+  directly from the asset layer and your middleware (auth, security headers)
+  never runs for them. List every non-asset pattern the Worker owns; keep the
+  asset platform serving the rest (it's free and cached).
+- `not_found_handling: "single-page-application"` gives deep links
+  (`/app/settings`) the shell with a 200; the SPA router takes over.
+- Responses from `ASSETS.fetch` have **immutable headers** — outbound middleware
+  must rebuild the Response to add headers (middleware.md).
+- Cache behaviour: the asset layer sets sane defaults (hashed assets long-lived,
+  HTML no-cache). Add app-owned headers via the outbound middleware if needed.
+
+## `fetch` + `scheduled` + `queue` in one export
+
+Hono owns HTTP; the other handlers sit beside it in the default export:
+
+```typescript
+export default {
+  fetch: app.fetch,
+
+  scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
+    // Branch on the cron expression — one Worker, many schedules (all listed in
+    // wrangler config `triggers.crons`). Keep each branch a thin dispatcher.
+    if (controller.cron === '*/5 * * * *') {
+      ctx.waitUntil(drainNotifications(env));
+      ctx.waitUntil(generateRecurring(env));   // self-guarded: no-op when already done
+    } else if (controller.cron === '0 2 * * *') {
+      ctx.waitUntil(nightlySync(env));
+    } else {
+      ctx.waitUntil(weeklyJobs(env));
+    }
+  },
+
+  async queue(batch: MessageBatch<JobMsg>, env: Env, ctx: ExecutionContext) {
+    for (const msg of batch.messages) {
+      try { await handleJob(msg.body, env); msg.ack(); }
+      catch { msg.retry(); }
+    }
+  },
+} satisfies ExportedHandler<Env>;
+```
+
+Discipline that keeps this maintainable:
+
+- **`satisfies ExportedHandler<Env>`** typechecks the whole export against the
+  runtime contract without widening.
+- **Independent `ctx.waitUntil` per job**, not one chained promise — one job's
+  failure must not suppress its siblings.
+- **Cron jobs are self-guarding**: gate on config presence (no token → no-op),
+  on state ("already generated this month"), and wrap per-item work in
+  try/catch so one bad item doesn't kill the sweep. Crons re-run; make them
+  idempotent.
+- Cron/queue code shares the domain layer with HTTP handlers — it just isn't
+  behind Hono, so nothing from the middleware context (identity, scoped repo)
+  exists. Build the equivalent explicitly (a system identity, per-tenant loops).
+- Local testing: `wrangler dev --test-scheduled` exposes
+  `GET /__scheduled?cron=*+*+*+*+*`; in vitest, call `scheduled()` directly with
+  a stub controller/ctx (testing.md).
+
+## `waitUntil` semantics
+
+`ctx.waitUntil(p)` (in Hono: `c.executionCtx.waitUntil(p)`) keeps the invocation
+alive until `p` settles, *after* the response is sent.
+
+- Register **before returning** — a floating promise not passed to `waitUntil`
+  is cancelled when the response completes.
+- Use it for: notification fan-out, cache writes, audit logs — anything the
+  caller shouldn't wait for and can survive losing.
+- Don't use it for work the response's correctness depends on, or anything
+  needing a guaranteed outcome (that's a queue's job — `waitUntil` work is lost
+  on isolate eviction/crash and has a post-response time budget).
+- Errors inside a `waitUntil` promise don't affect the response; they surface in
+  logs/tail only. Wrap in try/catch that records failure somewhere durable if
+  you'd need to know.
+
+## The `caches` API is per-colo
+
+`caches.default` / `caches.open()` is a **per-data-center** cache, not a global
+store:
+
+- A `cache.put` in one colo is invisible in every other; hit rate follows
+  traffic locality.
+- Correct uses: collapsing a poll storm (many clients, one upstream call per
+  ~45s window per colo), response caching where recomputation is cheap-but-annoying.
+- Wrong uses: anything that must be seen globally after a write (that's KV — 
+  eventually consistent — or D1/DO for strong consistency). There is no
+  cross-colo invalidation; design for TTL expiry, not purge.
+- Cache keys must be derived from **trusted, resolved** values (e.g. the
+  post-authorization resource id), never raw client input — a key built from an
+  unvalidated query param lets one caller poison another's cache line.
+
+## Detached fetch — "Illegal invocation"
+
+Storing the global `fetch` on an object (the injectable-fetch testing pattern)
+and calling it as a method throws in workerd:
+
+```typescript
+class ApiClient {
+  constructor(private fetchImpl: typeof fetch = fetch) {}
+
+  async call(url: string) {
+    // BROKEN in Workers: this.fetchImpl(url) invokes fetch with `this` bound to
+    // the ApiClient instance -> TypeError: Illegal invocation.
+    // FIX: detach to a bare local so the receiver is stripped:
+    const doFetch = this.fetchImpl;
+    return doFetch(url);
+  }
+}
+```
+
+Notes: mock `fetchImpl`s in tests are plain functions and never trip this — the
+bug ships to production while the suite stays green, which is exactly why the
+detach idiom should be unconditional. `const doFetch = this.fetchImpl ?? fetch`
+and `fetch.bind(globalThis)` also work; the bare-local detach is the
+lowest-ceremony fix. (Node ≥18 has the same receiver rule, so the idiom is
+portable.)
+
+## Assorted runtime traps
+
+| Trap | Detail |
+|---|---|
+| Module state ≠ per-request state | Module-level variables persist across requests in an isolate (good: JWKS cache; bad: anything request-scoped — that's `c.set`) |
+| No timers between requests | An isolate may be evicted anytime after the response (+`waitUntil` budget); never rely on `setInterval`/background loops — that's cron's job |
+| `wrangler dev` host rewrite | Dev rewrites the request host to your route pattern; pin `[dev] host` when middleware branches on hostname |
+| Bundle size | Workers has a compressed-size limit; validator libs and polyfills add up — prefer tree-shakeable deps (valibot, `zod/mini`) when close to it |
+| Secrets gating | Optional secrets (`KEY?: string`): the dependent route returns 503 and the cron no-ops while unset. Never log secret values; log presence booleans |
+| Subrequest limits | Each request has a subrequest budget; per-item external calls inside a big loop belong in a queue consumer, not a request handler |

+ 230 - 0
skills/hono-ops/scripts/check-hono-facts.py

@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+"""Staleness verifier for hono-ops: the documented Hono major line and the
+named ecosystem packages must stay real, stated, and current.
+
+hono-ops assumes Hono v4 and names @hono/zod-validator and
+@cloudflare/vitest-pool-workers as the validation/testing packages. Those are
+the facts that drift silently (SKILL-RESOURCE-PROTOCOL.md §7): Hono ships a v5
+and the middleware/RPC advice quietly rots, or a package is renamed and every
+install command in the prose 404s. Two modes:
+
+  --offline (default, safe for PR CI): structural consistency, no network.
+    * assets/hono-facts.json parses, carries the schema + an as_of date
+    * every catalogued fact's prose_token is still named in the skill prose
+      (SKILL.md + references/*.md + assets/worker-template.ts)
+    * SKILL.md still carries a dated "Verified against Hono v<major> (<year>)"
+      currency note whose major matches the catalog
+  --live (scheduled freshness job, never a PR gate): does each package still
+    resolve on npm, and has hono's major moved off the documented line?
+
+Usage:   check-hono-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S] [-q]
+Input:   argv flags only (no stdin).
+Output:  stdout = findings (plain rows, or a --json envelope). Data only.
+Stderr:  the verdict line, notices, errors.
+Exit:    0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable,
+         7 npm unreachable (live, advisory - never a real failure),
+         10 drift found (offline: fact no longer named / currency note gone or
+            mismatched; live: package gone from npm or hono major drifted)
+
+Examples:
+  check-hono-facts.py --offline                 # PR CI: facts <-> prose consistency
+  check-hono-facts.py --live                    # weekly: hono still v4 on npm?
+  check-hono-facts.py --offline --json | jq '.data[]'
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+EX_OK = 0
+EX_USAGE = 2
+EX_NOTFOUND = 3
+EX_UNPARSEABLE = 4
+EX_UNAVAILABLE = 7
+EX_DRIFT = 10
+
+SCHEMA = "claude-mods.hono-ops.facts/v1"
+FACT_KEYS = ("hono", "zod_validator", "pool_workers")
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_CATALOG = HERE.parent / "assets" / "hono-facts.json"
+DEFAULT_SKILL = HERE.parent
+
+NPM_REGISTRY = "https://registry.npmjs.org"
+
+CURRENCY_RE = re.compile(r"Verified against Hono v(\d+)\s*\((\d{4})\)", re.IGNORECASE)
+AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+
+def load_catalog(path: Path) -> dict:
+    if not path.is_file():
+        print(f"error: facts catalog not found: {path}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+        if not isinstance(data, dict) or data.get("schema") != SCHEMA:
+            raise ValueError(f"schema must be {SCHEMA!r}")
+        if not AS_OF_RE.match(str(data.get("as_of", ""))):
+            raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
+        for key in FACT_KEYS:
+            fact = data.get(key)
+            if not isinstance(fact, dict) or "prose_token" not in fact or "package" not in fact:
+                raise ValueError(f"fact {key!r} missing prose_token/package")
+        return data
+    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
+        print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+
+
+def read_corpus(skill_dir: Path) -> tuple[str, str]:
+    """Return (skill_md_text, all_prose_text) across SKILL.md + references/*.md
+    + assets/worker-template.ts (the starter names Hono v4 in its header)."""
+    doc = skill_dir / "SKILL.md"
+    if not doc.is_file():
+        print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    skill_md = doc.read_text(encoding="utf-8", errors="replace")
+    parts = [skill_md]
+    ref_dir = skill_dir / "references"
+    if ref_dir.is_dir():
+        for ref in sorted(ref_dir.glob("*.md")):
+            parts.append(ref.read_text(encoding="utf-8", errors="replace"))
+    template = skill_dir / "assets" / "worker-template.ts"
+    if template.is_file():
+        parts.append(template.read_text(encoding="utf-8", errors="replace"))
+    return skill_md, "\n".join(parts)
+
+
+def check_offline(catalog: dict, skill_dir: Path) -> list[dict]:
+    skill_md, corpus = read_corpus(skill_dir)
+    lower = corpus.lower()
+    findings: list[dict] = []
+
+    m = CURRENCY_RE.search(skill_md)
+    if not m:
+        findings.append({"check": "currency-note", "status": "drift",
+                         "detail": "no dated 'Verified against Hono v<major> (<year>)' note in SKILL.md"})
+    elif m.group(1) != str(catalog["hono"].get("documented_major")):
+        findings.append({"check": "currency-note", "status": "drift",
+                         "detail": f"currency note says v{m.group(1)} but catalog documents v{catalog['hono'].get('documented_major')}"})
+    else:
+        findings.append({"check": "currency-note", "status": "ok",
+                         "detail": f"currency note v{m.group(1)} dated {m.group(2)}"})
+
+    for key in FACT_KEYS:
+        token = str(catalog[key]["prose_token"])
+        if token.lower() in lower:
+            findings.append({"check": f"fact:{key}", "status": "ok",
+                             "detail": f"{token!r} named in skill prose"})
+        else:
+            findings.append({"check": f"fact:{key}", "status": "drift",
+                             "detail": f"prose_token {token!r} no longer named in skill prose"})
+    return findings
+
+
+def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
+    """Return (status, version-or-detail). status in ok|notfound|unavailable."""
+    url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='@')}/latest"
+    req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-hono-ops-check/1",
+                                               "Accept": "application/json"})
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            payload = resp.read().decode("utf-8", errors="replace")
+    except urllib.error.HTTPError as exc:
+        if exc.code in (404, 410):
+            return "notfound", str(exc.code)
+        return "unavailable", str(exc.code)
+    except (urllib.error.URLError, TimeoutError, OSError):
+        return "unavailable", ""
+    try:
+        return "ok", json.loads(payload).get("version", "")
+    except json.JSONDecodeError:
+        return "unavailable", "bad-json"
+
+
+def check_live(catalog: dict, timeout: float) -> list[dict]:
+    findings: list[dict] = []
+    for key in FACT_KEYS:
+        fact = catalog[key]
+        pkg = str(fact["package"])
+        status, ver = _npm_latest(pkg, timeout)
+        if status == "notfound":
+            findings.append({"check": f"npm:{key}", "status": "drift",
+                             "detail": f"{pkg} gone from npm - renamed/removed, review skill"})
+            continue
+        if status != "ok":
+            findings.append({"check": f"npm:{key}", "status": "unavailable",
+                             "detail": f"npm registry unreachable for {pkg}"})
+            continue
+        documented = fact.get("documented_major")
+        m = re.match(r"\s*(\d+)", ver)
+        latest_major = m.group(1) if m else ""
+        if documented is not None and latest_major and latest_major != str(documented):
+            findings.append({"check": f"npm:{key}", "status": "drift",
+                             "detail": f"{pkg}@{ver} major {latest_major} != documented v{documented}.x - review skill"})
+        else:
+            findings.append({"check": f"npm:{key}", "status": "ok",
+                             "detail": f"latest {ver}"})
+    return findings
+
+
+def main(argv: list[str]) -> int:
+    p = argparse.ArgumentParser(
+        prog="check-hono-facts.py",
+        description="Verify hono-ops' Hono major + package facts stay stated (offline) and current (live).",
+        epilog=(
+            "Examples:\n"
+            "  check-hono-facts.py --offline\n"
+            "  check-hono-facts.py --live\n"
+            "  check-hono-facts.py --offline --json | jq '.data[]'\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    mode = p.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
+    mode.add_argument("--live", action="store_true", help="probe npm for package/major drift")
+    p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
+    p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/ + assets/)")
+    p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
+    p.add_argument("--json", action="store_true", help="emit a JSON envelope")
+    p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
+    try:
+        args = p.parse_args(argv)
+    except SystemExit as exc:
+        return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
+
+    catalog = load_catalog(Path(args.catalog))
+    live = args.live
+    findings = (check_live(catalog, args.timeout) if live
+                else check_offline(catalog, Path(args.skill)))
+
+    drift = [f for f in findings if f["status"] == "drift"]
+    unavailable = [f for f in findings if f["status"] == "unavailable"]
+
+    if args.json:
+        print(json.dumps({"data": findings,
+                          "meta": {"count": len(findings), "mode": "live" if live else "offline",
+                                   "schema": SCHEMA}}, indent=2))
+    else:
+        for f in findings:
+            print(f"{f['check']}\t{f['status']}\t{f['detail']}")
+
+    if not args.quiet:
+        verdict = ("DRIFT" if drift else "UNAVAILABLE" if unavailable else "OK")
+        print(f"check-hono-facts: {verdict} ({len(findings)} checks, {len(drift)} drift)", file=sys.stderr)
+
+    if drift:
+        return EX_DRIFT
+    if unavailable:
+        return EX_UNAVAILABLE
+    return EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 237 - 0
skills/hono-ops/scripts/route-inventory.py

@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""List a Hono app's routes, middleware, and mounts from TypeScript source; lint registration order.
+
+Usage:   route-inventory.py [OPTIONS] PATH
+Input:   PATH — a .ts/.tsx file or a source directory (scanned recursively;
+         node_modules, dist, build, and dot-directories are skipped)
+Output:  stdout, data only.
+         Default: TSV rows  kind<TAB>method<TAB>path<TAB>app<TAB>file:line
+         --json:  {"data": [...], "meta": {"count": N, "schema":
+                  "claude-mods.hono-ops.route-inventory/v1"}}
+         --check: findings only (same TSV/JSON shape, kind=finding)
+Stderr:  headers, progress, warnings, errors
+Exit:    0 ok/clean, 2 usage, 3 path not found, 10 --check found routes that
+         bypass a later-registered middleware (confirm each is deliberate)
+
+Notes:   Pure-regex static analysis (no TypeScript compiler API required — the
+         native TS 7 toolchain ships none). Per-file: `app.route()` mounts are
+         listed but sub-app files are not expanded into their mount prefix.
+         The --check linter flags handlers registered BEFORE a middleware whose
+         path pattern covers them — those handlers silently skip it (Hono
+         matches in registration order). A flagged route is either the #1 Hono
+         ordering bug or a deliberate pre-auth exception; the linter's job is
+         to make you say which.
+
+Examples:
+  route-inventory.py src/
+  route-inventory.py --json src/ | jq '.data[] | select(.kind=="mount")'
+  route-inventory.py --check src/            # exit 10 = order findings
+  route-inventory.py --check --json src/index.ts
+"""
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+SCHEMA = "claude-mods.hono-ops.route-inventory/v1"
+METHODS = ("get", "post", "put", "patch", "delete", "options", "all")
+SKIP_DIRS = {"node_modules", "dist", "build", "coverage"}
+
+# Statement-style: `app.get(` / `export const x = app.route(` etc.
+STMT_RE = re.compile(
+    r"^\s*(?:export\s+)?(?:const\s+\w+\s*=\s*)?(?:await\s+)?"
+    r"(?P<app>[A-Za-z_$][\w$]*)\.(?P<verb>get|post|put|patch|delete|options|all|on|use|route|onError|notFound)\s*\("
+)
+# Chained-style continuation: `  .get('/x', ...)`
+CHAIN_RE = re.compile(r"^\s*\.(?P<verb>get|post|put|patch|delete|options|all|on|use|route)\s*\(")
+# `const app = new Hono<...>()` — anchors chained-call attribution to the right app var.
+NEW_APP_RE = re.compile(r"^\s*(?:export\s+)?(?:const|let|var)\s+(?P<app>[A-Za-z_$][\w$]*)\s*=\s*new\s+Hono\b")
+
+
+def string_args(line: str, start: int, max_args: int = 2) -> list[str]:
+    """Return up to max_args leading string-literal arguments after position `start`.
+
+    Stops at the first argument that is not a plain string literal (template
+    literals and identifiers end the scan — we only trust what we can read).
+    """
+    out: list[str] = []
+    i = start
+    n = len(line)
+    while len(out) < max_args:
+        while i < n and line[i] in " \t":
+            i += 1
+        if i >= n or line[i] not in "'\"":
+            break
+        quote = line[i]
+        j = i + 1
+        buf = []
+        while j < n and line[j] != quote:
+            if line[j] == "\\" and j + 1 < n:
+                buf.append(line[j + 1])
+                j += 2
+                continue
+            buf.append(line[j])
+            j += 1
+        if j >= n:  # unterminated on this line — bail
+            break
+        out.append("".join(buf))
+        i = j + 1
+        while i < n and line[i] in " \t":
+            i += 1
+        if i < n and line[i] == ",":
+            i += 1
+        else:
+            break
+    return out
+
+
+def pattern_to_regex(pattern: str) -> re.Pattern:
+    """Compile a Hono path pattern ('*', '/api/*', '/x/:id') to a full-match regex."""
+    if pattern in ("", "*", "/*"):
+        return re.compile(r".*")
+    esc = re.escape(pattern)
+    esc = esc.replace(r"\*", ".*")
+    esc = re.sub(r"\\:[A-Za-z_][\w]*", r"[^/]+", esc)
+    return re.compile(esc + r"$")
+
+
+def scan_file(path: Path, root: Path) -> list[dict]:
+    entries: list[dict] = []
+    try:
+        text = path.read_text(encoding="utf-8", errors="replace")
+    except OSError as e:
+        print(f"warning: unreadable {path}: {e}", file=sys.stderr)
+        return entries
+    rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else str(path)
+    last_app = "(chained)"
+    for lineno, line in enumerate(text.splitlines(), 1):
+        stripped = line.lstrip()
+        if stripped.startswith(("//", "*", "/*")):
+            continue
+        new_app = NEW_APP_RE.match(line)
+        if new_app:
+            last_app = new_app.group("app")
+            continue
+        m = STMT_RE.match(line)
+        chained = False
+        if not m:
+            m = CHAIN_RE.match(line)
+            chained = bool(m)
+            if not m:
+                continue
+        verb = m.group("verb")
+        app = m.group("app") if not chained else last_app
+        if not chained:
+            last_app = app
+        args = string_args(line, m.end())
+        if verb == "on":
+            method = args[0].upper() if args else "?"
+            route_path = args[1] if len(args) > 1 else "?"
+            kind = "route"
+        elif verb in METHODS:
+            method, route_path, kind = verb.upper(), (args[0] if args else "?"), "route"
+        elif verb == "use":
+            method, route_path, kind = "*", (args[0] if args else "*"), "middleware"
+        elif verb == "route":
+            method, route_path, kind = "-", (args[0] if args else "?"), "mount"
+        else:  # onError / notFound
+            method, route_path, kind = "-", "-", verb
+        entries.append({
+            "kind": kind, "method": method, "path": route_path,
+            "app": app, "file": rel, "line": lineno,
+        })
+    return entries
+
+
+def check_order(entries: list[dict]) -> list[dict]:
+    """Flag routes registered before a same-file, same-app middleware that covers them."""
+    findings: list[dict] = []
+    by_file_app: dict[tuple, list[dict]] = {}
+    for e in entries:
+        by_file_app.setdefault((e["file"], e["app"]), []).append(e)
+    for group in by_file_app.values():
+        middlewares = [e for e in group if e["kind"] == "middleware" and e["path"] != "?"]
+        routes = [e for e in group if e["kind"] in ("route", "mount") and e["path"] not in ("?", "-")]
+        for mw in middlewares:
+            rx = pattern_to_regex(mw["path"])
+            for r in routes:
+                if r["line"] < mw["line"] and rx.match(r["path"]):
+                    findings.append({
+                        "kind": "finding",
+                        "method": r["method"],
+                        "path": r["path"],
+                        "app": r["app"],
+                        "file": r["file"],
+                        "line": r["line"],
+                        "detail": (
+                            f"registered before middleware use('{mw['path']}') at "
+                            f"{mw['file']}:{mw['line']} - this {r['kind']} bypasses it"
+                        ),
+                    })
+    return findings
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser(
+        prog="route-inventory.py",
+        description="List a Hono app's routes/middleware/mounts; lint registration order.",
+        epilog=(
+            "Examples:\n"
+            "  route-inventory.py src/\n"
+            "  route-inventory.py --json src/ | jq '.data[]'\n"
+            "  route-inventory.py --check src/    # exit 10 on findings\n"
+        ),
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    ap.add_argument("path", help="Hono TypeScript source file or directory")
+    ap.add_argument("--json", action="store_true", help="emit the JSON envelope instead of TSV")
+    ap.add_argument("--check", action="store_true",
+                    help="middleware-order lint: report routes a later middleware would have covered (exit 10)")
+    try:
+        args = ap.parse_args()
+    except SystemExit as e:
+        # argparse exits 2 on usage errors and 0 on --help; preserve both.
+        return int(e.code or 0)
+
+    target = Path(args.path)
+    if not target.exists():
+        if args.json:
+            print(json.dumps({"error": {"code": "NOT_FOUND", "message": f"path not found: {target}", "details": {}}}))
+        print(f"error: path not found: {target}", file=sys.stderr)
+        return 3
+
+    root = target if target.is_dir() else target.parent
+    files = (
+        sorted(
+            p for p in target.rglob("*")
+            if p.suffix in (".ts", ".tsx")
+            and not any(part in SKIP_DIRS or part.startswith(".") for part in p.parts)
+        )
+        if target.is_dir() else [target]
+    )
+
+    entries: list[dict] = []
+    for f in files:
+        entries.extend(scan_file(f, root))
+    print(f"scanned {len(files)} file(s), {len(entries)} registration(s)", file=sys.stderr)
+
+    rows = check_order(entries) if args.check else entries
+    if args.json:
+        print(json.dumps({"data": rows, "meta": {"count": len(rows), "schema": SCHEMA}}, indent=2))
+    else:
+        for r in rows:
+            loc = f"{r['file']}:{r['line']}"
+            tail = f"\t{r['detail']}" if "detail" in r else ""
+            print(f"{r['kind']}\t{r['method']}\t{r['path']}\t{r['app']}\t{loc}{tail}")
+
+    if args.check and rows:
+        print(f"{len(rows)} route(s) bypass a later-registered middleware - confirm each is deliberate",
+              file=sys.stderr)
+        return 10
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 37 - 0
skills/hono-ops/tests/fixtures/sample-app.ts

@@ -0,0 +1,37 @@
+// Test fixture for route-inventory.py — a miniature composition root exercising
+// every registration shape the scanner claims to parse. NOT runnable code; the
+// identifiers are stubs. tests/run.sh asserts exact counts against this file,
+// so adding a registration here means updating those assertions.
+import { Hono } from 'hono';
+
+export const app = new Hono<{ Bindings: Env; Variables: Vars }>();
+
+app.use('*', securityHeaders());
+
+// Deliberately registered BEFORE the /api/* auth middleware: the --check linter
+// must flag this route as bypassing it (the fixture's one expected finding).
+app.get('/api/health', (c) => c.json({ ok: true }));
+
+app.use('/api/*', authMiddleware);
+
+app.get('/api/me', (c) => c.json({}));
+app.post('/api/things', (c) => c.json({}, 201));
+app.patch('/api/things/:id', (c) => c.json({}));
+app.delete('/api/things/:id', (c) => c.json({ ok: true }));
+app.on('PURGE', '/api/cache', (c) => c.json({ ok: true }));
+
+app.route('/api/time', timeApi);
+app.route('/vesper', vesper);
+
+app.all('/api/*', (c) => c.json({ error: 'not_found' }, 404));
+app.all('*', (c) => c.env.ASSETS.fetch(c.req.raw));
+
+app.onError((err, c) => c.json({ error: 'internal' }, 500));
+
+// Chained definition (RPC style) — attribution must follow the new app var,
+// not stick to `app` from the statements above.
+const chained = new Hono()
+  .get('/posts/:id', (c) => c.json({ post: null }))
+  .post('/posts', (c) => c.json({ ok: true }, 201));
+
+export type ChainedType = typeof chained;

+ 131 - 0
skills/hono-ops/tests/run.sh

@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# Offline self-test for the hono-ops skill — structure, frontmatter, and the
+# script contracts (SKILL-RESOURCE-PROTOCOL §2, §5, §7, §10).
+#
+# Usage:   tests/run.sh
+# Input:   none (self-contained; no network, no node/wrangler install required)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
+#
+# Examples:
+#   tests/run.sh
+#   bash skills/hono-ops/tests/run.sh
+set -uo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fail=0
+pass=0
+note() { printf '  %s %s\n' "$1" "$2" >&2; }
+ok()   { pass=$((pass+1)); note "ok  " "$1"; }
+bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
+
+# Resolve a *working* python (python3, else python). The bare `command -v` is not
+# enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero.
+PY=""
+for cand in python3 python; do
+  if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
+    PY="$cand"; break
+  fi
+done
+if [ -z "$PY" ]; then
+  echo "SKIP: no working python interpreter on this platform" >&2
+  exit 0
+fi
+
+# 1. Required directories exist
+for d in scripts references assets tests; do
+  [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
+done
+
+# 2. SKILL.md frontmatter house rules
+# CONTRACT: these assertions require the frontmatter to keep `name: hono-ops`,
+# `license: MIT`, and `metadata.author: claude-mods` — a trim/cleanup lane that
+# edits the frontmatter must keep them or update these assertions in the same
+# commit (see SKILL-CREATION-PROTOCOL Step 5).
+skill="$here/SKILL.md"
+if [ -f "$skill" ]; then
+  ok "SKILL.md present"
+  grep -q '^name: hono-ops$' "$skill" && ok "name matches directory" || bad "name != hono-ops"
+  grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
+  grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
+else
+  bad "SKILL.md missing"
+fi
+
+# 3. Every reference on disk is cited from SKILL.md (no dead weight)
+for ref in "$here"/references/*.md; do
+  base="references/$(basename "$ref")"
+  grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
+done
+
+# 4. Every SKILL.md-cited bundled resource exists on disk
+for res in assets/worker-template.ts assets/hono-facts.json \
+           scripts/route-inventory.py scripts/check-hono-facts.py \
+           tests/fixtures/sample-app.ts; do
+  [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
+done
+
+# Helper: assert an exact exit code.
+ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
+       [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; }
+
+# 5. route-inventory.py — script contract + behaviour on the bundled fixture
+inv="$here/scripts/route-inventory.py"
+fixture="$here/tests/fixtures/sample-app.ts"
+"$PY" -m py_compile "$inv" && ok "route-inventory: py_compile clean" || bad "route-inventory: py_compile failed"
+"$PY" "$inv" --help 2>/dev/null | grep -q "Examples:" && ok "route-inventory: --help has Examples" || bad "route-inventory: --help missing Examples"
+ec 0 "route-inventory: --help exits 0" "$PY" "$inv" --help
+ec 2 "route-inventory: bad flag -> 2"  "$PY" "$inv" --bogus "$fixture"
+ec 3 "route-inventory: missing path -> 3" "$PY" "$inv" /no/such/path
+ec 0 "route-inventory: fixture inventory ok" "$PY" "$inv" "$fixture"
+
+# Behaviour: the fixture registers exactly 15 things; assert the count and a few
+# load-bearing rows (a mount, the custom method, chained-app attribution).
+# CONTRACT: tests/fixtures/sample-app.ts and these numbers move together.
+out="$("$PY" "$inv" "$fixture" 2>/dev/null)"
+[ "$(printf '%s\n' "$out" | wc -l)" = "15" ] && ok "route-inventory: 15 rows" || bad "route-inventory: row count != 15"
+printf '%s\n' "$out" | grep -q "mount	-	/api/time" && ok "route-inventory: mount row" || bad "route-inventory: mount row missing"
+printf '%s\n' "$out" | grep -q "route	PURGE	/api/cache" && ok "route-inventory: on() custom method" || bad "route-inventory: on() row missing"
+printf '%s\n' "$out" | grep -q "route	GET	/posts/:id	chained" && ok "route-inventory: chained attribution" || bad "route-inventory: chained attribution wrong"
+
+# --json envelope parses with the documented schema (stdout is data-only)
+"$PY" "$inv" --json "$fixture" 2>/dev/null \
+  | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.hono-ops.route-inventory/v1"; assert d["meta"]["count"]==15' \
+  && ok "route-inventory: --json envelope parses" || bad "route-inventory: --json envelope broken"
+
+# --check flags exactly the fixture's one deliberate pre-middleware route
+ec 10 "route-inventory: --check finds order issue -> 10" "$PY" "$inv" --check "$fixture"
+chk="$("$PY" "$inv" --check "$fixture" 2>/dev/null)"
+[ "$(printf '%s\n' "$chk" | grep -c '^finding')" = "1" ] && ok "route-inventory: exactly 1 finding" || bad "route-inventory: finding count != 1"
+printf '%s\n' "$chk" | grep -q "/api/health" && ok "route-inventory: finding names /api/health" || bad "route-inventory: wrong finding"
+
+# 6. check-hono-facts.py — staleness verifier contract (§7), offline-safe
+verifier="$here/scripts/check-hono-facts.py"
+"$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed"
+grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)"
+ec 0 "verifier: --help exits 0" "$PY" "$verifier" --help
+ec 0 "verifier: --offline consistent" "$PY" "$verifier" --offline
+ec 2 "verifier: bad flag -> 2" "$PY" "$verifier" --bogus
+ec 2 "verifier: --offline --live -> 2" "$PY" "$verifier" --offline --live
+ec 3 "verifier: missing catalog -> 3" "$PY" "$verifier" --offline --catalog /no/such/catalog.json
+"$PY" "$verifier" --offline --json -q 2>/dev/null \
+  | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.hono-ops.facts/v1"' \
+  && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken"
+
+# Error paths against synthetic catalogs: malformed -> 4, drifted token -> 10.
+tmp="$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}/hono-ops-test.$$")"
+mkdir -p "$tmp"
+printf 'not json' > "$tmp/bad.json"
+ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$tmp/bad.json"
+sed 's/Hono v4/Hono v99/' "$here/assets/hono-facts.json" > "$tmp/drift.json"
+ec 10 "verifier: drifted token -> 10" "$PY" "$verifier" --offline --catalog "$tmp/drift.json"
+rm -rf "$tmp"
+
+# 7. Template sanity: the starter names the load-bearing sections
+tmpl="$here/assets/worker-template.ts"
+for marker in "securityHeaders" "app.onError" "satisfies ExportedHandler" "not_found" "timingSafeEqual"; do
+  grep -qF "$marker" "$tmpl" && ok "template carries: $marker" || bad "template missing: $marker"
+done
+
+echo "hono-ops tests: $pass passed, $fail failed" >&2
+[ "$fail" = "0" ] && { echo "PASS" >&2; exit 0; } || { echo "FAIL" >&2; exit 1; }

+ 9 - 1
tests/check-resources.sh

@@ -86,6 +86,12 @@ echo "== isometric-ops: projection-constant/staleness verifier"
 run "iso-facts --offline consistent" 0 "$PY" skills/isometric-ops/scripts/check-iso-facts.py --offline
 run "iso-facts --help"               0 "$PY" skills/isometric-ops/scripts/check-iso-facts.py --help
 
+echo "== hono-ops: Hono fact/staleness verifier + route-inventory contract"
+run "hono-facts --offline consistent" 0 "$PY" skills/hono-ops/scripts/check-hono-facts.py --offline
+run "hono-facts --help"               0 "$PY" skills/hono-ops/scripts/check-hono-facts.py --help
+run "route-inventory --help"          0 "$PY" skills/hono-ops/scripts/route-inventory.py --help
+run "route-inventory fixture scan"    0 "$PY" skills/hono-ops/scripts/route-inventory.py skills/hono-ops/tests/fixtures/sample-app.ts
+
 echo "== protocol: every new verifier is executable + compiles"
 for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/claude-code-ops/scripts/validate-hooks-json.py \
@@ -94,7 +100,9 @@ for s in skills/claude-api-ops/scripts/check-model-table.py \
          skills/loop-ops/scripts/check-pricing-sync.py \
          skills/r-ops/scripts/check-r-facts.py \
          skills/threejs-ops/scripts/check-three-facts.py \
-         skills/isometric-ops/scripts/check-iso-facts.py; do
+         skills/isometric-ops/scripts/check-iso-facts.py \
+         skills/hono-ops/scripts/check-hono-facts.py \
+         skills/hono-ops/scripts/route-inventory.py; do
     "$PY" -m py_compile "$s" 2>/dev/null && pass "py_compile $(basename "$s")" || bad "py_compile $(basename "$s")"
 done
 bash -n skills/terraform-ops/scripts/check-action-refs.sh 2>/dev/null \