diff --git a/deno.json b/deno.json
index b6ebcba0..e9bd3d9f 100644
--- a/deno.json
+++ b/deno.json
@@ -37,7 +37,7 @@
"fmt:check": "deno fmt --check",
"check": "deno task check:src && deno task check:worker",
"check:slow-types": "deno publish --dry-run",
- "check:drift": "git diff --quiet docs/api/cloudflare-schema.yaml docs/postman/postman-collection.json docs/postman/postman-environment.json || (printf '\\n❌ Generated files are out of date. Run: deno task schema:generate\\n Then: git add docs/api/cloudflare-schema.yaml docs/postman/postman-collection.json docs/postman/postman-environment.json\\n Finally: commit the updated files (for example: git commit -m \"chore: update generated schemas\")\\n' && exit 1)",
+ "check:drift": "git diff --quiet docs/api/cloudflare-schema.yaml docs/postman/postman-collection.json docs/postman/postman-environment.json || (printf '\\n\u274c Generated files are out of date. Run: deno task schema:generate\\n Then: git add docs/api/cloudflare-schema.yaml docs/postman/postman-collection.json docs/postman/postman-environment.json\\n Finally: commit the updated files (for example: git commit -m \"chore: update generated schemas\")\\n' && exit 1)",
"cache": "deno cache src/index.ts",
"openapi:validate": "deno run --allow-read --allow-net scripts/validate-openapi.ts",
"openapi:docs": "deno run --allow-read --allow-write --allow-net scripts/generate-docs.ts",
@@ -48,7 +48,7 @@
"generate:schema": "deno task schema:cloudflare && deno run --allow-read --allow-write --allow-env scripts/generate-openapi-schema.ts",
"preflight": "deno task fmt:check && deno task lint && deno task check && deno task openapi:validate && deno task schema:generate && deno task check:drift",
"preflight:full": "deno task preflight && deno task test && deno task check:slow-types",
- "setup": "deno run --allow-read --allow-write scripts/setup-env.ts && deno task db:generate && deno task setup:hooks && echo '✅ Setup complete! Edit .env.local and .dev.vars with your credentials, then run: deno task wrangler:dev'",
+ "setup": "deno run --allow-read --allow-write scripts/setup-env.ts && deno task db:generate && deno task setup:hooks && echo '\u2705 Setup complete! Edit .env.local and .dev.vars with your credentials, then run: deno task wrangler:dev'",
"setup:hooks": "deno run --allow-read --allow-write --allow-run scripts/setup-hooks.ts",
"test:contract": "SKIP_CONTRACT_TESTS=false deno test --allow-read --allow-write --allow-net --allow-env worker/openapi-contract.test.ts",
"migrate:validate": "deno run --allow-read scripts/validate-migrations.ts",
@@ -91,7 +91,9 @@
"lighthouse:run": "pnpm exec lhci autorun",
"lighthouse:install": "pnpm add --global @lhci/cli",
"check:src": "deno check src/index.ts src/cli.ts",
- "check:worker": "deno check worker/worker.ts worker/tail.ts"
+ "check:worker": "deno check worker/worker.ts worker/tail.ts",
+ "trpc:types": "deno check worker/trpc/router.ts",
+ "check:trpc": "deno check worker/trpc/router.ts worker/trpc/client.ts"
},
"imports": {
"@/": "./src/",
diff --git a/deno.lock b/deno.lock
index 0292e5ea..eca8a14e 100644
--- a/deno.lock
+++ b/deno.lock
@@ -31,21 +31,23 @@
"npm:@opentelemetry/api@^1.9.0": "1.9.0",
"npm:@prisma/adapter-d1@^7.5.0": "7.5.0",
"npm:@prisma/adapter-pg@^7.5.0": "7.5.0",
- "npm:@prisma/client@^7.5.0": "7.5.0_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4",
- "npm:@prisma/internals@7.5.0": "7.5.0",
+ "npm:@prisma/client@^7.5.0": "7.5.0_prisma@7.5.0__typescript@6.0.2_typescript@6.0.2",
+ "npm:@prisma/internals@7.5.0": "7.5.0_typescript@6.0.2",
"npm:@sentry/cloudflare@10.43.0": "10.43.0_@cloudflare+workers-types@4.20260317.1",
"npm:@sentry/cloudflare@^10.43.0": "10.43.0_@cloudflare+workers-types@4.20260317.1",
+ "npm:@trpc/client@^11.15.1": "11.15.1_@trpc+server@11.15.1__typescript@6.0.2_typescript@6.0.2",
+ "npm:@trpc/server@^11.15.1": "11.15.1_typescript@6.0.2",
"npm:agents@0.8": "0.8.1_ai@6.0.138__zod@4.3.6_react@19.2.4_zod@4.3.6_@cloudflare+workers-types@4.20260317.1",
- "npm:better-auth@^1.5.5": "1.5.6_@prisma+client@7.5.0__prisma@7.5.0___@types+react@19.2.14___react@19.2.4___react-dom@19.2.4____react@19.2.4__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_pg@8.13.3_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4",
- "npm:better-auth@^1.5.6": "1.5.6_@prisma+client@7.5.0__prisma@7.5.0___@types+react@19.2.14___react@19.2.4___react-dom@19.2.4____react@19.2.4__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_pg@8.13.3_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4",
+ "npm:better-auth@^1.5.5": "1.5.6_@prisma+client@7.5.0__prisma@7.5.0___typescript@6.0.2__typescript@6.0.2_pg@8.13.3_prisma@7.5.0__typescript@6.0.2",
+ "npm:better-auth@^1.5.6": "1.5.6_@prisma+client@7.5.0__prisma@7.5.0___typescript@6.0.2__typescript@6.0.2_pg@8.13.3_prisma@7.5.0__typescript@6.0.2",
"npm:cloudflare@5.2.0": "5.2.0",
"npm:cloudflare@^5.2.0": "5.2.0",
"npm:hono@^4.12.8": "4.12.8",
"npm:jose@^6.2.1": "6.2.1",
"npm:pg@8.13.3": "8.13.3",
- "npm:prisma@*": "7.5.0_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4",
- "npm:prisma@7.5.0": "7.5.0_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4",
- "npm:prisma@^7.5.0": "7.5.0_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4",
+ "npm:prisma@*": "7.5.0_typescript@6.0.2",
+ "npm:prisma@7.5.0": "7.5.0_typescript@6.0.2",
+ "npm:prisma@^7.5.0": "7.5.0_typescript@6.0.2",
"npm:svix@^1.62.0": "1.89.0",
"npm:svix@^1.89.0": "1.89.0",
"npm:wrangler@^4.77.0": "4.77.0_@cloudflare+workers-types@4.20260317.1",
@@ -274,7 +276,7 @@
"@better-auth/utils"
]
},
- "@better-auth/prisma-adapter@1.5.6_@better-auth+core@1.5.6__@better-auth+utils@0.3.1__@better-fetch+fetch@1.1.21__@cloudflare+workers-types@4.20260317.1__@opentelemetry+api@1.9.0__better-call@1.3.2___zod@4.3.6__jose@6.2.1__kysely@0.28.14__nanostores@1.2.0_@better-auth+utils@0.3.1_@prisma+client@7.5.0__prisma@7.5.0___@types+react@19.2.14___react@19.2.4___react-dom@19.2.4____react@19.2.4__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_@better-fetch+fetch@1.1.21_@cloudflare+workers-types@4.20260317.1_@opentelemetry+api@1.9.0_better-call@1.3.2__zod@4.3.6_jose@6.2.1_kysely@0.28.14_nanostores@1.2.0": {
+ "@better-auth/prisma-adapter@1.5.6_@better-auth+core@1.5.6__@better-auth+utils@0.3.1__@better-fetch+fetch@1.1.21__@cloudflare+workers-types@4.20260317.1__@opentelemetry+api@1.9.0__better-call@1.3.2___zod@4.3.6__jose@6.2.1__kysely@0.28.14__nanostores@1.2.0_@better-auth+utils@0.3.1_@prisma+client@7.5.0__prisma@7.5.0___typescript@6.0.2__typescript@6.0.2_prisma@7.5.0__typescript@6.0.2_typescript@6.0.2": {
"integrity": "sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==",
"dependencies": [
"@better-auth/core",
@@ -853,14 +855,16 @@
"@prisma/client-runtime-utils@7.5.0": {
"integrity": "sha512-KnJ2b4Si/pcWEtK68uM+h0h1oh80CZt2suhLTVuLaSKg4n58Q9jBF/A42Kw6Ma+aThy1yAhfDeTC0JvEmeZnFQ=="
},
- "@prisma/client@7.5.0_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4": {
+ "@prisma/client@7.5.0_prisma@7.5.0__typescript@6.0.2_typescript@6.0.2": {
"integrity": "sha512-h4hF9ctp+kSRs7ENHGsFQmHAgHcfkOCxbYt6Ti9Xi8x7D+kP4tTi9x51UKmiTH/OqdyJAO+8V+r+JA5AWdav7w==",
"dependencies": [
"@prisma/client-runtime-utils",
- "prisma"
+ "prisma",
+ "typescript"
],
"optionalPeers": [
- "prisma"
+ "prisma",
+ "typescript"
]
},
"@prisma/config@7.5.0": {
@@ -878,7 +882,7 @@
"@prisma/debug@7.5.0": {
"integrity": "sha512-163+nffny0JoPEkDhfNco0vcuT3ymIJc9+WX7MHSQhfkeKUmKe9/wqvGk5SjppT93DtBjVwr5HPJYlXbzm6qtg=="
},
- "@prisma/dev@0.20.0": {
+ "@prisma/dev@0.20.0_typescript@6.0.2": {
"integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==",
"dependencies": [
"@electric-sql/pglite",
@@ -953,7 +957,7 @@
"@prisma/debug@7.5.0"
]
},
- "@prisma/internals@7.5.0": {
+ "@prisma/internals@7.5.0_typescript@6.0.2": {
"integrity": "sha512-c+W0pr0qBa1Zdw5iVnSKSGC6NtTD4HPBB8L5YNxg1OBvj3ZoCnZZZRseDSDTt3Dy7MneTlYon4M/NndBSZaXtA==",
"dependencies": [
"@prisma/config",
@@ -969,7 +973,11 @@
"@prisma/schema-engine-wasm",
"@prisma/schema-files-loader",
"arg",
- "prompts"
+ "prompts",
+ "typescript"
+ ],
+ "optionalPeers": [
+ "typescript"
]
},
"@prisma/prisma-schema-wasm@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e": {
@@ -1022,6 +1030,21 @@
"@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
+ "@trpc/client@11.15.1_@trpc+server@11.15.1__typescript@6.0.2_typescript@6.0.2": {
+ "integrity": "sha512-Zav9uPSEM7zBlEbttKep1kCfxHumB7P/e/zVFspzfyeB6XYGVeILFeZVL6cnODkgUIFSzgO9X4fXRnn0BP/BhQ==",
+ "dependencies": [
+ "@trpc/server",
+ "typescript"
+ ],
+ "bin": true
+ },
+ "@trpc/server@11.15.1_typescript@6.0.2": {
+ "integrity": "sha512-0A1fIBU0zDLXaSOhuHOChqM4mCCCi233FcPdPNXJ+FIVMd5VEGe33u6cehUavZMquIi6uIec9xymac2P4LgqMA==",
+ "dependencies": [
+ "typescript"
+ ],
+ "bin": true
+ },
"@types/diff-match-patch@1.0.36": {
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="
},
@@ -1184,7 +1207,7 @@
"aws-ssl-profiles@1.1.2": {
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="
},
- "better-auth@1.5.6_@prisma+client@7.5.0__prisma@7.5.0___@types+react@19.2.14___react@19.2.4___react-dom@19.2.4____react@19.2.4__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4_pg@8.13.3_prisma@7.5.0__@types+react@19.2.14__react@19.2.4__react-dom@19.2.4___react@19.2.4": {
+ "better-auth@1.5.6_@prisma+client@7.5.0__prisma@7.5.0___typescript@6.0.2__typescript@6.0.2_pg@8.13.3_prisma@7.5.0__typescript@6.0.2": {
"integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==",
"dependencies": [
"@better-auth/core",
@@ -2240,7 +2263,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"bin": true
},
- "prisma@7.5.0_@types+react@19.2.14_react@19.2.4_react-dom@19.2.4__react@19.2.4": {
+ "prisma@7.5.0_typescript@6.0.2": {
"integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==",
"dependencies": [
"@prisma/config",
@@ -2248,7 +2271,11 @@
"@prisma/engines",
"@prisma/studio-core",
"mysql2",
- "postgres"
+ "postgres",
+ "typescript"
+ ],
+ "optionalPeers": [
+ "typescript"
],
"scripts": true,
"bin": true
@@ -2583,6 +2610,10 @@
"mime-types@3.0.2"
]
},
+ "typescript@6.0.2": {
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "bin": true
+ },
"undici-types@5.26.5": {
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
@@ -2611,8 +2642,14 @@
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"bin": true
},
- "valibot@1.2.0": {
- "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="
+ "valibot@1.2.0_typescript@6.0.2": {
+ "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==",
+ "dependencies": [
+ "typescript"
+ ],
+ "optionalPeers": [
+ "typescript"
+ ]
},
"vary@1.1.2": {
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
@@ -2932,6 +2969,8 @@
"npm:@prisma/adapter-pg@^7.5.0",
"npm:@prisma/client@^7.5.0",
"npm:@sentry/cloudflare@10.43.0",
+ "npm:@trpc/client@^11.15.1",
+ "npm:@trpc/server@^11.15.1",
"npm:agents@0.8",
"npm:better-auth@^1.5.6",
"npm:cloudflare@^5.2.0",
diff --git a/docs/architecture/hono-routing.md b/docs/architecture/hono-routing.md
index be5e3714..f53141bd 100644
--- a/docs/architecture/hono-routing.md
+++ b/docs/architecture/hono-routing.md
@@ -210,6 +210,26 @@ async (c) => {
---
+## tRPC endpoint
+
+The tRPC v11 handler is mounted directly on the top-level `app` at `/api/trpc/*`:
+
+```typescript
+// Mounted BEFORE app.route('/api', routes) so the routes sub-app
+// (with compress + logger middleware) never wraps tRPC responses.
+app.all('/api/trpc/*', (c) => handleTrpcRequest(c));
+```
+
+This placement ensures:
+
+- The global middleware chain (timing, request metadata, auth, CORS, secure headers)
+ **does** run before tRPC requests — `authContext` is already populated.
+- The `compress()` and `logger()` middleware scoped to the `routes` sub-app **do not**
+ wrap tRPC responses.
+
+See [`docs/architecture/trpc.md`](./trpc.md) for the full tRPC procedure catalogue,
+client usage, and ZTA notes.
+
---
## Phase 4 — Domain Route Modules (complete)
@@ -274,3 +294,4 @@ were needed.
Run manually with: `deno task lint:routes`
+
diff --git a/docs/architecture/trpc.md b/docs/architecture/trpc.md
new file mode 100644
index 00000000..194002f2
--- /dev/null
+++ b/docs/architecture/trpc.md
@@ -0,0 +1,88 @@
+# tRPC API Layer
+
+## Overview
+
+The adblock-compiler Worker exposes a typed tRPC v11 API alongside the existing REST endpoints.
+All tRPC procedures are available at `/api/trpc/*`.
+
+## Versioning
+
+Procedures are namespaced by version: `v1.*`. The `v1` namespace is stable.
+Breaking changes (removed procedures, changed input shapes) will be introduced under `v2`
+without removing `v1`.
+
+## Procedure catalogue
+
+### v1.health.get (query, public)
+
+Returns the same payload as `GET /api/health`.
+
+### v1.compile.json (mutation, authenticated)
+
+Accepts a `CompileRequestSchema` body. Returns the compiled ruleset JSON.
+
+### v1.version.get (query, public)
+
+Returns `{ version, apiVersion }`.
+
+## Angular client
+
+```typescript
+import { createTrpcClient } from './trpc-client';
+
+const client = createTrpcClient(environment.apiBaseUrl, () => authService.getToken());
+const result = await client.v1.compile.json.mutate({
+ configuration: {
+ sources: [
+ {
+ url: 'https://example.com/easylist.txt',
+ },
+ ],
+ },
+ preFetchedContent: {},
+});
+```
+
+## Adding a new procedure
+
+1. Create (or extend) a router file in `worker/trpc/routers/v1/`.
+2. Add it to `worker/trpc/routers/v1/index.ts`.
+3. No changes to `hono-app.ts` required — the tRPC handler is already mounted.
+
+## Mount point
+
+The tRPC handler is mounted directly on the top-level `app` (not the `routes` sub-app)
+so that the `compress` and `logger` middleware scoped to business routes do not wrap
+tRPC responses. See [`hono-routing.md`](./hono-routing.md#trpc-endpoint) for details.
+
+```mermaid
+flowchart TD
+ R[Incoming Request] --> M1[Global middleware\ntiming · metadata · auth · CORS]
+ M1 --> TRPC{path starts with\n/api/trpc?}
+ TRPC -->|yes| TH[handleTrpcRequest\nfetchRequestHandler]
+ TH --> PROC{procedure auth?}
+ PROC -->|publicProcedure| HANDLER[Execute handler]
+ PROC -->|protectedProcedure| AUTH_CHECK{userId non-null?}
+ AUTH_CHECK -->|no| UNAUTH[TRPCError UNAUTHORIZED\n+ security telemetry]
+ AUTH_CHECK -->|yes| HANDLER
+ PROC -->|adminProcedure| ADMIN_CHECK{role === admin?}
+ ADMIN_CHECK -->|no| FORB[TRPCError FORBIDDEN\n+ security telemetry]
+ ADMIN_CHECK -->|yes| HANDLER
+ HANDLER --> RESP[Response]
+ TRPC -->|no| REST[REST routes sub-app]
+```
+
+## ZTA notes
+
+- `protectedProcedure` enforces non-anonymous auth (returns `UNAUTHORIZED` if
+ `authContext.userId` is null).
+- `adminProcedure` additionally enforces `role === 'admin'` (returns `FORBIDDEN`).
+- Auth failures emit `AnalyticsService.trackSecurityEvent()` via the `onError` hook
+ in `worker/trpc/handler.ts`.
+- `/api/trpc/*` has its own `app.use('/api/trpc/*', rateLimitMiddleware())` middleware
+ applied directly on `app` before `handleTrpcRequest`, enforcing tiered rate limits
+ (including `rate_limit` security telemetry) for all tRPC calls.
+- `/api/trpc/*` has its own ZTA access-gate middleware that calls `checkUserApiAccess()`
+ (blocks banned/suspended users) and `trackApiUsage()` (billing/analytics) —
+ matching the same checks applied to REST routes by `routes.use('*', ...)`.
+- CORS is inherited from the global `cors()` middleware already in place on `app`.
diff --git a/package.json b/package.json
index 3345a2ed..4aa041ee 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,8 @@
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"@sentry/cloudflare": "10.43.0",
+ "@trpc/client": "^11.15.1",
+ "@trpc/server": "^11.15.1",
"better-auth": "^1.5.6",
"cloudflare": "^5.2.0",
"hono": "^4.12.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1c8468c7..4e370226 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,12 @@ importers:
'@sentry/cloudflare':
specifier: 10.43.0
version: 10.43.0(@cloudflare/workers-types@4.20260317.1)
+ '@trpc/client':
+ specifier: ^11.15.1
+ version: 11.15.1(@trpc/server@11.15.1(typescript@6.0.2))(typescript@6.0.2)
+ '@trpc/server':
+ specifier: ^11.15.1
+ version: 11.15.1(typescript@6.0.2)
better-auth:
specifier: ^1.5.6
version: 1.5.6(@cloudflare/workers-types@4.20260317.1)(@opentelemetry/api@1.9.0)(@prisma/client@7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2))(typescript@6.0.2))(mongodb@7.1.0(socks@2.8.7))(mysql2@3.15.3)(pg@8.20.0)(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.31.1)(sass@1.97.3)(terser@5.46.1)(yaml@2.8.3)))
@@ -3426,6 +3432,19 @@ packages:
'@tanstack/query-core@5.90.16':
resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==}
+ '@trpc/client@11.15.1':
+ resolution: {integrity: sha512-Zav9uPSEM7zBlEbttKep1kCfxHumB7P/e/zVFspzfyeB6XYGVeILFeZVL6cnODkgUIFSzgO9X4fXRnn0BP/BhQ==}
+ hasBin: true
+ peerDependencies:
+ '@trpc/server': 11.15.1
+ typescript: '>=5.7.2'
+
+ '@trpc/server@11.15.1':
+ resolution: {integrity: sha512-0A1fIBU0zDLXaSOhuHOChqM4mCCCi233FcPdPNXJ+FIVMd5VEGe33u6cehUavZMquIi6uIec9xymac2P4LgqMA==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.7.2'
+
'@ts-morph/common@0.22.0':
resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==}
@@ -11574,6 +11593,15 @@ snapshots:
'@tanstack/query-core@5.90.16': {}
+ '@trpc/client@11.15.1(@trpc/server@11.15.1(typescript@6.0.2))(typescript@6.0.2)':
+ dependencies:
+ '@trpc/server': 11.15.1(typescript@6.0.2)
+ typescript: 6.0.2
+
+ '@trpc/server@11.15.1(typescript@6.0.2)':
+ dependencies:
+ typescript: 6.0.2
+
'@ts-morph/common@0.22.0':
dependencies:
fast-glob: 3.3.3
diff --git a/worker/hono-app.ts b/worker/hono-app.ts
index 8ff7386e..dedc515b 100644
--- a/worker/hono-app.ts
+++ b/worker/hono-app.ts
@@ -10,8 +10,9 @@
*
* Phase 3 progressive enhancements:
* - Migrates `app` and `routes` to `OpenAPIHono` (from `@hono/zod-openapi`)
- * - Shared helpers: `zodValidationError`, `verifyTurnstileInline`, `buildSyntheticRequest`
* - `timing()` middleware adds `Server-Timing` headers to every response
+ * - `X-API-Version: v1` response header on every response
+ * - tRPC v11 handler mounted at `/api/trpc/*` (see `worker/trpc/`)
* - `prettyJSON()` globally (activate with `?pretty=true`)
* - `compress()` on the `routes` sub-app for automatic response compression (gzip/deflate)
* - `logger()` on the `routes` sub-app for standardized request/response logging
@@ -20,9 +21,11 @@
*
* @see docs/architecture/hono-routing.md — architecture overview
* @see docs/architecture/hono-rpc-client.md — typed RPC client pattern
+ * @see docs/architecture/trpc.md — tRPC API layer
* @see worker/handlers/router.ts — thin re-export shim (backward compat)
* @see worker/middleware/hono-middleware.ts — Phase 2 middleware factories
* @see worker/routes/ — domain-scoped route modules
+ * @see worker/trpc/ — tRPC routers, context, and handler
*/
///
@@ -47,6 +50,7 @@ import { WORKER_DEFAULTS } from '../src/config/defaults.ts';
// Middleware
import { checkRateLimitTiered } from './middleware/index.ts';
+import { rateLimitMiddleware } from './middleware/hono-middleware.ts';
import { authenticateRequestUnified } from './middleware/auth.ts';
import { BetterAuthProvider } from './middleware/better-auth-provider.ts';
@@ -66,6 +70,9 @@ import { isPublicEndpoint, matchOrigin } from './utils/cors.ts';
// Handlers (pre-auth meta routes — eagerly imported)
import { handleAuthProviders } from './handlers/auth-providers.ts';
+// tRPC
+import { handleTrpcRequest } from './trpc/handler.ts';
+
// Agent routing (authenticated)
import { agentRouter } from './agents/index.ts';
@@ -180,6 +187,12 @@ app.onError((err, c) => {
// ── 0. Server-Timing middleware ───────────────────────────────────────────────
app.use('*', timing());
+// ── 0a. API versioning header — set on every response (including errors) ──────
+app.use('*', async (c, next) => {
+ c.header('X-API-Version', 'v1');
+ await next();
+});
+
// ── 1. Request metadata middleware ────────────────────────────────────────────
app.use('*', async (c, next) => {
c.set('requestId', generateRequestId('api'));
@@ -552,6 +565,40 @@ app.get('/api/openapi.json', (c) => {
return c.json(spec);
});
+// tRPC — all versions, public + authenticated
+// Auth context is already set by the global middleware chain above.
+// Mounted directly on `app` (not the `routes` sub-app) to avoid the
+// compress/logger middleware that is scoped to business routes.
+
+// ── Tiered rate-limiting for all tRPC calls ───────────────────────────────────
+// Mirrors the per-endpoint rateLimitMiddleware() applied to REST write routes.
+app.use('/api/trpc/*', rateLimitMiddleware());
+
+// ── ZTA access gate + usage tracking for tRPC ────────────────────────────────
+// Mirrors the routes.use('*', ...) middleware that applies checkUserApiAccess()
+// and trackApiUsage() to every REST endpoint in the `routes` sub-app.
+app.use('/api/trpc/*', async (c, next) => {
+ const authContext = c.get('authContext');
+ const analytics = c.get('analytics');
+ const ip = c.get('ip');
+
+ const accessDenied = await checkUserApiAccess(authContext, c.env);
+ if (accessDenied) {
+ analytics.trackSecurityEvent({
+ eventType: 'auth_failure',
+ path: c.req.path,
+ method: c.req.method,
+ clientIpHash: AnalyticsService.hashIp(ip),
+ reason: 'api_disabled',
+ });
+ return accessDenied;
+ }
+ c.executionCtx.waitUntil(trackApiUsage(authContext, c.req.path, c.req.method, c.env));
+ await next();
+});
+
+app.all('/api/trpc/*', (c) => handleTrpcRequest(c));
+
app.route('/api', routes);
// NOTE: app.route('/', routes) was intentionally removed in Phase 4 (domain route split).
diff --git a/worker/trpc/client.ts b/worker/trpc/client.ts
new file mode 100644
index 00000000..f14ea98c
--- /dev/null
+++ b/worker/trpc/client.ts
@@ -0,0 +1,30 @@
+/**
+ * Typed tRPC client for Angular and other API consumers.
+ *
+ * Usage in Angular service:
+ * const client = createTrpcClient('https://adblock-compiler.jayson-knight.workers.dev',
+ * () => authService.getToken());
+ * const health = await client.v1.health.get.query();
+ * const result = await client.v1.compile.json.mutate({
+ * configuration: {
+ * sources: [{ url: 'https://example.com/filters.txt' }],
+ * },
+ * });
+ */
+
+import { createTRPCClient, httpBatchLink } from '@trpc/client';
+import type { AppRouter } from './router.ts';
+
+export function createTrpcClient(baseUrl: string, getToken?: () => Promise) {
+ return createTRPCClient({
+ links: [
+ httpBatchLink({
+ url: `${baseUrl}/api/trpc`,
+ async headers() {
+ const token = await getToken?.();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ },
+ }),
+ ],
+ });
+}
diff --git a/worker/trpc/context.ts b/worker/trpc/context.ts
new file mode 100644
index 00000000..929e34b8
--- /dev/null
+++ b/worker/trpc/context.ts
@@ -0,0 +1,38 @@
+/**
+ * tRPC context factory.
+ *
+ * Creates the tRPC context from a Hono request context.
+ * The global unified-auth middleware in hono-app.ts already populates
+ * `authContext` on `c` before the tRPC handler is reached.
+ */
+
+import type { Context } from 'hono';
+import type { Env, IAuthContext } from '../types.ts';
+import type { AnalyticsService } from '../../src/services/AnalyticsService.ts';
+
+/** Minimal subset of Hono Variables needed by the tRPC context. */
+interface AppVars {
+ authContext: IAuthContext;
+ analytics: AnalyticsService;
+ requestId: string;
+ ip: string;
+ isSSR: boolean;
+}
+
+export interface TrpcContext {
+ env: Env;
+ authContext: IAuthContext;
+ requestId: string;
+ ip: string;
+ analytics: AnalyticsService;
+}
+
+export function createTrpcContext(c: Context<{ Bindings: Env; Variables: AppVars }>): TrpcContext {
+ return {
+ env: c.env,
+ authContext: c.get('authContext'),
+ requestId: c.get('requestId') ?? crypto.randomUUID(),
+ ip: c.get('ip') ?? '',
+ analytics: c.get('analytics'),
+ };
+}
diff --git a/worker/trpc/handler.ts b/worker/trpc/handler.ts
new file mode 100644
index 00000000..878d41f3
--- /dev/null
+++ b/worker/trpc/handler.ts
@@ -0,0 +1,52 @@
+/**
+ * Hono adapter for the tRPC router.
+ *
+ * Mounts the tRPC app at `/api/trpc` using the fetch adapter.
+ * Auth context is already populated by the global middleware chain before
+ * this handler runs — no additional middleware wiring is needed.
+ *
+ * ZTA telemetry: UNAUTHORIZED and FORBIDDEN tRPC errors emit
+ * `AnalyticsService.trackSecurityEvent()`.
+ */
+
+import { TRPCError } from '@trpc/server';
+import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
+import type { Context } from 'hono';
+import { AnalyticsService } from '../../src/services/AnalyticsService.ts';
+import type { Env, IAuthContext } from '../types.ts';
+import { appRouter } from './router.ts';
+import { createTrpcContext } from './context.ts';
+
+/** Minimal subset of Hono Variables needed by the tRPC handler. */
+interface AppVars {
+ authContext: IAuthContext;
+ analytics: AnalyticsService;
+ requestId: string;
+ ip: string;
+ isSSR: boolean;
+}
+
+export async function handleTrpcRequest(c: Context<{ Bindings: Env; Variables: AppVars }>): Promise {
+ const analytics = c.get('analytics');
+ const ip = c.get('ip') ?? '';
+
+ return fetchRequestHandler({
+ endpoint: '/api/trpc',
+ req: c.req.raw,
+ router: appRouter,
+ createContext: () => createTrpcContext(c),
+ onError({ error, path }) {
+ if (error instanceof TRPCError) {
+ if (error.code === 'UNAUTHORIZED' || error.code === 'FORBIDDEN') {
+ analytics?.trackSecurityEvent({
+ eventType: 'auth_failure',
+ path: `/api/trpc/${path ?? ''}`,
+ method: c.req.method,
+ clientIpHash: AnalyticsService.hashIp(ip),
+ reason: error.code === 'UNAUTHORIZED' ? 'trpc_unauthorized' : 'trpc_forbidden',
+ });
+ }
+ }
+ },
+ });
+}
diff --git a/worker/trpc/init.ts b/worker/trpc/init.ts
new file mode 100644
index 00000000..c69b3e9b
--- /dev/null
+++ b/worker/trpc/init.ts
@@ -0,0 +1,33 @@
+/**
+ * tRPC initialisation — defines the base `t` instance and procedure builders.
+ *
+ * Procedure types:
+ * - `publicProcedure` — no auth required
+ * - `protectedProcedure` — requires authenticated session (userId non-null)
+ * - `adminProcedure` — requires admin role
+ */
+
+import { initTRPC, TRPCError } from '@trpc/server';
+import type { TrpcContext } from './context.ts';
+
+const t = initTRPC.context().create();
+
+export const router = t.router;
+export const publicProcedure = t.procedure;
+export const createCallerFactory = t.createCallerFactory;
+
+/** Procedure that requires an authenticated session (non-anonymous authContext). */
+export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
+ if (!ctx.authContext.userId) {
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Authentication required.' });
+ }
+ return next({ ctx });
+});
+
+/** Procedure that requires admin role. */
+export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
+ if (ctx.authContext.role !== 'admin') {
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin role required.' });
+ }
+ return next({ ctx });
+});
diff --git a/worker/trpc/router.ts b/worker/trpc/router.ts
new file mode 100644
index 00000000..bd443831
--- /dev/null
+++ b/worker/trpc/router.ts
@@ -0,0 +1,15 @@
+/**
+ * Top-level versioned tRPC router.
+ *
+ * All tRPC procedures are namespaced under `v1`. Future breaking changes will
+ * be introduced under `v2` without removing `v1`.
+ */
+
+import { router } from './init.ts';
+import { v1Router } from './routers/v1/index.ts';
+
+export const appRouter = router({
+ v1: v1Router,
+});
+
+export type AppRouter = typeof appRouter;
diff --git a/worker/trpc/routers/v1/compile.router.ts b/worker/trpc/routers/v1/compile.router.ts
new file mode 100644
index 00000000..cf592441
--- /dev/null
+++ b/worker/trpc/routers/v1/compile.router.ts
@@ -0,0 +1,36 @@
+/**
+ * tRPC v1 compile router.
+ *
+ * v1.compile.json (mutation, authenticated) — accepts a CompileRequestSchema body
+ * and returns the compiled ruleset JSON.
+ */
+
+import { TRPCError } from '@trpc/server';
+import { z } from 'zod';
+import { protectedProcedure, router } from '../../init.ts';
+import { CompileRequestSchema } from '../../../../src/configuration/schemas.ts';
+import { handleCompileJson } from '../../../handlers/compile.ts';
+import { buildSyntheticRequest } from '../../../utils/synthetic-request.ts';
+
+export const compileRouter = router({
+ json: protectedProcedure
+ // Use a parser function to avoid the jsr:@zod/zod ↔ npm:zod module-identity
+ // mismatch that would force `as any`. The function approach preserves full
+ // TypeScript inference of the compile request type on both client and server.
+ .input((input: unknown): z.infer => {
+ const result = CompileRequestSchema.safeParse(input);
+ if (!result.success) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: result.error.message,
+ cause: result.error,
+ });
+ }
+ return result.data;
+ })
+ .mutation(async ({ input, ctx }) => {
+ const req = buildSyntheticRequest(JSON.stringify(input));
+ const res = await handleCompileJson(req, ctx.env, ctx.analytics, ctx.requestId);
+ return res.json();
+ }),
+});
diff --git a/worker/trpc/routers/v1/health.router.ts b/worker/trpc/routers/v1/health.router.ts
new file mode 100644
index 00000000..5e1046fe
--- /dev/null
+++ b/worker/trpc/routers/v1/health.router.ts
@@ -0,0 +1,15 @@
+/**
+ * tRPC v1 health router.
+ *
+ * v1.health.get (query, public) — returns the same payload as GET /api/health.
+ */
+
+import { publicProcedure, router } from '../../init.ts';
+import { handleHealth } from '../../../handlers/health.ts';
+
+export const healthRouter = router({
+ get: publicProcedure.query(async ({ ctx }) => {
+ const res = await handleHealth(ctx.env);
+ return res.json();
+ }),
+});
diff --git a/worker/trpc/routers/v1/index.ts b/worker/trpc/routers/v1/index.ts
new file mode 100644
index 00000000..1b9bbf27
--- /dev/null
+++ b/worker/trpc/routers/v1/index.ts
@@ -0,0 +1,16 @@
+/**
+ * tRPC v1 barrel — assembles all v1 procedure routers.
+ */
+
+import { router } from '../../init.ts';
+import { healthRouter } from './health.router.ts';
+import { compileRouter } from './compile.router.ts';
+import { versionRouter } from './version.router.ts';
+
+export const v1Router = router({
+ health: healthRouter,
+ compile: compileRouter,
+ version: versionRouter,
+});
+
+export type V1Router = typeof v1Router;
diff --git a/worker/trpc/routers/v1/version.router.ts b/worker/trpc/routers/v1/version.router.ts
new file mode 100644
index 00000000..19999d74
--- /dev/null
+++ b/worker/trpc/routers/v1/version.router.ts
@@ -0,0 +1,14 @@
+/**
+ * tRPC v1 version router.
+ *
+ * v1.version.get (query, public) — returns the Worker version and API version.
+ */
+
+import { publicProcedure, router } from '../../init.ts';
+
+export const versionRouter = router({
+ get: publicProcedure.query(({ ctx }) => ({
+ version: ctx.env.COMPILER_VERSION || 'unknown',
+ apiVersion: 'v1',
+ })),
+});
diff --git a/worker/trpc/trpc.test.ts b/worker/trpc/trpc.test.ts
new file mode 100644
index 00000000..6287086d
--- /dev/null
+++ b/worker/trpc/trpc.test.ts
@@ -0,0 +1,170 @@
+/**
+ * Unit tests for the tRPC v1 router.
+ *
+ * Uses `createCallerFactory` to invoke procedures directly (no HTTP overhead).
+ */
+
+import { assertEquals, assertRejects } from '@std/assert';
+import { TRPCError } from '@trpc/server';
+import { UserTier } from '../types.ts';
+import { createCallerFactory } from './init.ts';
+import { appRouter } from './router.ts';
+import { makeEnv } from '../test-helpers.ts';
+import type { TrpcContext } from './context.ts';
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const env = makeEnv({ COMPILER_VERSION: 'test-1.0.0' });
+
+/** Anonymous context (no authenticated user). */
+const anonCtx: TrpcContext = {
+ env,
+ authContext: {
+ userId: null,
+ tier: UserTier.Anonymous,
+ role: 'anonymous',
+ apiKeyId: null,
+ sessionId: null,
+ scopes: [],
+ authMethod: 'anonymous',
+ email: null,
+ displayName: null,
+ apiKeyRateLimit: null,
+ },
+ requestId: 'test-request-id',
+ ip: '127.0.0.1',
+ analytics: {
+ trackSecurityEvent: () => {},
+ trackApiUsage: () => {},
+ trackError: () => {},
+ // deno-lint-ignore no-explicit-any
+ } as any,
+};
+
+/** Authenticated (free-tier) user context. */
+const authedCtx: TrpcContext = {
+ ...anonCtx,
+ authContext: {
+ ...anonCtx.authContext,
+ userId: 'user-123',
+ tier: UserTier.Free,
+ role: 'user',
+ authMethod: 'better-auth',
+ },
+};
+
+/** Admin context. */
+const adminCtx: TrpcContext = {
+ ...authedCtx,
+ authContext: {
+ ...authedCtx.authContext,
+ userId: 'admin-456',
+ role: 'admin',
+ },
+};
+
+const createCaller = createCallerFactory(appRouter);
+
+// ── v1.health.get ──────────────────────────────────────────────────────────────
+
+Deno.test('v1.health.get — returns parsed health JSON for anonymous callers', async () => {
+ const caller = createCaller(anonCtx);
+ // handleHealth reaches out to KV / Hyperdrive; with mock env it returns a
+ // degraded-but-valid JSON response.
+ // deno-lint-ignore no-explicit-any
+ const result = await caller.v1.health.get() as any;
+ // Must be an object with at least a `status` key.
+ assertEquals(typeof result, 'object');
+ assertEquals(typeof result.status, 'string');
+});
+
+// ── v1.version.get ─────────────────────────────────────────────────────────────
+
+Deno.test('v1.version.get — returns version and apiVersion', async () => {
+ const caller = createCaller(anonCtx);
+ const result = await caller.v1.version.get();
+ assertEquals(result.version, 'test-1.0.0');
+ assertEquals(result.apiVersion, 'v1');
+});
+
+Deno.test('v1.version.get — returns "unknown" when COMPILER_VERSION is absent', async () => {
+ const ctxNoVersion: TrpcContext = { ...anonCtx, env: makeEnv({ COMPILER_VERSION: '' }) };
+ const caller = createCaller(ctxNoVersion);
+ const result = await caller.v1.version.get();
+ // Empty string is falsy — the router uses `||` so it falls back to 'unknown'
+ assertEquals(result.version, 'unknown');
+ assertEquals(result.apiVersion, 'v1');
+});
+
+// ── v1.compile.json ─────────────────────────────────────────────────────────────
+
+Deno.test('v1.compile.json — rejects anonymous callers with UNAUTHORIZED', async () => {
+ const caller = createCaller(anonCtx);
+ // deno-lint-ignore no-explicit-any
+ await assertRejects(
+ // deno-lint-ignore no-explicit-any
+ () => caller.v1.compile.json({} as any),
+ TRPCError,
+ 'Authentication required.',
+ );
+});
+
+Deno.test('v1.compile.json — authenticated caller receives compile response', async () => {
+ const caller = createCaller(authedCtx);
+ // The CompileRequestSchema requires a `configuration.sources` field. Passing
+ // an empty object triggers a schema validation error (BAD_REQUEST) — not
+ // UNAUTHORIZED. This confirms auth passed and the handler was reached.
+ // deno-lint-ignore no-explicit-any
+ const err = await caller.v1.compile.json({} as any).catch((e) => e);
+ assertEquals(err instanceof TRPCError, true);
+ assertEquals((err as TRPCError).code, 'BAD_REQUEST');
+});
+
+// ── protectedProcedure auth enforcement ────────────────────────────────────────
+
+Deno.test('protectedProcedure — throws UNAUTHORIZED when userId is null', async () => {
+ const caller = createCaller(anonCtx);
+ // deno-lint-ignore no-explicit-any
+ const err = await caller.v1.compile.json({} as any).catch((e) => e);
+ assertEquals(err instanceof TRPCError, true);
+ assertEquals((err as TRPCError).code, 'UNAUTHORIZED');
+});
+
+Deno.test('protectedProcedure — allows request when userId is set', async () => {
+ const caller = createCaller(authedCtx);
+ // Should not throw UNAUTHORIZED. A BAD_REQUEST (schema validation) is expected
+ // because we're passing an empty object, not a valid CompileRequestSchema.
+ // deno-lint-ignore no-explicit-any
+ const err = await caller.v1.compile.json({} as any).catch((e) => e);
+ if (err instanceof TRPCError) {
+ assertEquals(err.code !== 'UNAUTHORIZED', true);
+ }
+});
+
+// ── adminProcedure auth enforcement ────────────────────────────────────────────
+
+Deno.test('adminProcedure — enforces admin role requirement', async () => {
+ // Import the init primitives directly to test adminProcedure in isolation.
+ const { router: r, adminProcedure } = await import('./init.ts');
+ const testRouter = r({
+ ping: adminProcedure.query(() => 'pong'),
+ });
+ const testCreate = createCallerFactory(testRouter);
+
+ // Anonymous → UNAUTHORIZED
+ const anonCaller = testCreate(anonCtx);
+ const err1 = await anonCaller.ping().catch((e) => e);
+ assertEquals(err1 instanceof TRPCError, true);
+ assertEquals((err1 as TRPCError).code, 'UNAUTHORIZED');
+
+ // Authenticated non-admin → FORBIDDEN
+ const userCaller = testCreate(authedCtx);
+ const err2 = await userCaller.ping().catch((e) => e);
+ assertEquals(err2 instanceof TRPCError, true);
+ assertEquals((err2 as TRPCError).code, 'FORBIDDEN');
+
+ // Admin → success
+ const adminCaller = testCreate(adminCtx);
+ const result = await adminCaller.ping();
+ assertEquals(result, 'pong');
+});
diff --git a/worker/utils/synthetic-request.ts b/worker/utils/synthetic-request.ts
new file mode 100644
index 00000000..b6313c8e
--- /dev/null
+++ b/worker/utils/synthetic-request.ts
@@ -0,0 +1,18 @@
+/**
+ * Utility: build a minimal synthetic POST Request from a JSON body string.
+ *
+ * Used by tRPC procedures (and any other callers outside the Hono middleware
+ * pipeline) to reconstruct a `Request` that existing handler functions
+ * (which accept the legacy `(Request, Env, ...)` signature) can parse.
+ *
+ * For Hono route handlers that already have `c.req.url` and
+ * `c.req.raw.headers` available, prefer `buildSyntheticRequest(c, validatedBody)`
+ * defined in `worker/routes/shared.ts` instead.
+ */
+export function buildSyntheticRequest(body: string): Request {
+ return new Request('https://worker.local', {
+ method: 'POST',
+ headers: new Headers({ 'Content-Type': 'application/json' }),
+ body,
+ });
+}