From 454318c942eb41d6c5f4baf091655ee2b55fa7ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:53:05 +0000 Subject: [PATCH 01/10] Initial plan From 7d9e8b764c86774398a322a821a1162440559f4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 04:07:07 +0000 Subject: [PATCH 02/10] feat: add tRPC v1 + API versioning (X-API-Version header) Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/b33ce10d-01bc-4189-b7b2-1993bf10a473 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- deno.json | 12 +- deno.lock | 77 +++++++--- docs/architecture/hono-routing.md | 22 +++ docs/architecture/trpc.md | 75 ++++++++++ worker/hono-app.ts | 49 +++++-- worker/trpc/client.ts | 26 ++++ worker/trpc/context.ts | 38 +++++ worker/trpc/handler.ts | 52 +++++++ worker/trpc/init.ts | 33 +++++ worker/trpc/router.ts | 15 ++ worker/trpc/routers/v1/compile.router.ts | 31 +++++ worker/trpc/routers/v1/health.router.ts | 15 ++ worker/trpc/routers/v1/index.ts | 16 +++ worker/trpc/routers/v1/version.router.ts | 14 ++ worker/trpc/trpc.test.ts | 170 +++++++++++++++++++++++ 15 files changed, 614 insertions(+), 31 deletions(-) create mode 100644 docs/architecture/trpc.md create mode 100644 worker/trpc/client.ts create mode 100644 worker/trpc/context.ts create mode 100644 worker/trpc/handler.ts create mode 100644 worker/trpc/init.ts create mode 100644 worker/trpc/router.ts create mode 100644 worker/trpc/routers/v1/compile.router.ts create mode 100644 worker/trpc/routers/v1/health.router.ts create mode 100644 worker/trpc/routers/v1/index.ts create mode 100644 worker/trpc/routers/v1/version.router.ts create mode 100644 worker/trpc/trpc.test.ts diff --git a/deno.json b/deno.json index 5c2f36f2..67e0d44d 100644 --- a/deno.json +++ b/deno.json @@ -36,7 +36,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", @@ -47,7 +47,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", @@ -90,7 +90,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/", @@ -123,7 +125,9 @@ "zod": "jsr:@zod/zod@^4.3.6", "hono": "npm:hono@^4.12.8", "@hono/zod-validator": "npm:@hono/zod-validator@^0.4.3", - "@hono/zod-openapi": "npm:@hono/zod-openapi@^1.2.2" + "@hono/zod-openapi": "npm:@hono/zod-openapi@^1.2.2", + "@trpc/server": "npm:@trpc/server@^11.0.0", + "@trpc/client": "npm:@trpc/client@^11.0.0" }, "lint": { "rules": { diff --git a/deno.lock b/deno.lock index b2c4966f..70a51732 100644 --- a/deno.lock +++ b/deno.lock @@ -30,21 +30,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": "11.15.1_@trpc+server@11.15.1__typescript@6.0.2_typescript@6.0.2", + "npm:@trpc/server@11": "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_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_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", @@ -273,7 +275,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", @@ -852,14 +854,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": { @@ -877,7 +881,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", @@ -952,7 +956,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", @@ -968,7 +972,11 @@ "@prisma/schema-engine-wasm", "@prisma/schema-files-loader", "arg", - "prompts" + "prompts", + "typescript" + ], + "optionalPeers": [ + "typescript" ] }, "@prisma/prisma-schema-wasm@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e": { @@ -1021,6 +1029,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==" }, @@ -1183,7 +1206,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_typescript@6.0.2": { "integrity": "sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==", "dependencies": [ "@better-auth/core", @@ -2239,7 +2262,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", @@ -2247,7 +2270,11 @@ "@prisma/engines", "@prisma/studio-core", "mysql2", - "postgres" + "postgres", + "typescript" + ], + "optionalPeers": [ + "typescript" ], "scripts": true, "bin": true @@ -2582,6 +2609,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==" }, @@ -2610,8 +2641,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==" @@ -2908,6 +2945,8 @@ "npm:@prisma/adapter-pg@^7.5.0", "npm:@prisma/client@^7.5.0", "npm:@sentry/cloudflare@^10.43.0", + "npm:@trpc/client@11", + "npm:@trpc/server@11", "npm:agents@0.8", "npm:better-auth@^1.5.5", "npm:cloudflare@^5.2.0", diff --git a/docs/architecture/hono-routing.md b/docs/architecture/hono-routing.md index 874726ed..9d42d588 100644 --- a/docs/architecture/hono-routing.md +++ b/docs/architecture/hono-routing.md @@ -209,6 +209,28 @@ 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 3 Roadmap - Generate OpenAPI spec from route + schema definitions using `hono/openapi` diff --git a/docs/architecture/trpc.md b/docs/architecture/trpc.md new file mode 100644 index 00000000..065475ac --- /dev/null +++ b/docs/architecture/trpc.md @@ -0,0 +1,75 @@ +# 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 '@worker/trpc/client.ts'; + +const client = createTrpcClient(environment.apiBaseUrl, () => authService.getToken()); +const result = await client.v1.compile.json.mutate({ urls: ['...'] }); +``` + +## 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`. +- The global rate-limit middleware runs before all tRPC requests (shared with REST + endpoints). +- CORS is inherited from the global `cors()` middleware already in place on `app`. diff --git a/worker/hono-app.ts b/worker/hono-app.ts index 93e3f226..102f0a1b 100644 --- a/worker/hono-app.ts +++ b/worker/hono-app.ts @@ -11,8 +11,10 @@ * Phase 3 progressive enhancements: * - Migrates `app` and `routes` to `OpenAPIHono` (from `@hono/zod-openapi`) * - Extends `zValidator` to POST /compile/stream, /compile/batch, /configuration/validate - * - Shared helpers: `zodValidationError`, `verifyTurnstileInline`, `buildSyntheticRequest` + * - Shared helpers: `zodValidationError`, `verifyTurnstileInline`, `buildSyntheticRequest`, `buildHonoRequest` * - `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/`) * - `etag()` on GET /metrics and GET /health for conditional request support * - `prettyJSON()` globally (activate with `?pretty=true`) * - `compress()` on the `routes` sub-app for automatic response compression (gzip/deflate) — scoped to business routes, never touches /api/auth/* @@ -24,8 +26,10 @@ * * @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/trpc/ — tRPC routers, context, and handler */ /// @@ -122,6 +126,9 @@ import { WebhookNotifyRequestSchema, } from './schemas.ts'; +// tRPC +import { handleTrpcRequest } from './trpc/handler.ts'; + // Agent routing (authenticated) import { agentRouter } from './agents/index.ts'; import { handleWebSocketUpgrade } from './websocket.ts'; @@ -258,7 +265,7 @@ async function verifyTurnstileInline(c: AppContext, token: string): Promise { // ── 0. Server-Timing middleware (must be first to wrap all operations) ──────── app.use('*', timing()); +// ── 0a. API versioning header — set on every response ──────────────────────── +app.use('*', async (c, next) => { + await next(); + c.header('X-API-Version', 'v1'); +}); + // ── 1. Request metadata middleware ──────────────────────────────────────────── app.use('*', async (c, next) => { c.set('requestId', generateRequestId('api')); @@ -814,7 +841,7 @@ routes.all('/queue/*', async (c) => { // 2. rateLimitMiddleware() — per-user/IP tiered quota (429) // 3. zValidator() — structural body validation (422) — consumes body // 4. Inline Turnstile check — reads token from c.req.valid('json') -// 5. buildSyntheticRequest() — re-creates the Request for the handler +// 5. buildHonoRequest() — re-creates the Request for the handler // // These routes use `zValidator` BEFORE Turnstile verification so the body // stream is consumed exactly once. `turnstileMiddleware()` would clone+parse, @@ -839,7 +866,7 @@ routes.post( if (turnstileError) return turnstileError; // Reconstruct a Request from the validated (and sanitised) data so the // existing handler signature (Request, Env, ...) is preserved. - return handleCompileJson(buildSyntheticRequest(c, c.req.valid('json')), c.env, c.get('analytics'), c.get('requestId')); + return handleCompileJson(buildHonoRequest(c, c.req.valid('json')), c.env, c.get('analytics'), c.get('requestId')); }, ); @@ -853,7 +880,7 @@ routes.post( // deno-lint-ignore no-explicit-any const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); if (turnstileError) return turnstileError; - return handleCompileStream(buildSyntheticRequest(c, c.req.valid('json')), c.env); + return handleCompileStream(buildHonoRequest(c, c.req.valid('json')), c.env); }, ); @@ -867,7 +894,7 @@ routes.post( // deno-lint-ignore no-explicit-any const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); if (turnstileError) return turnstileError; - return handleCompileBatch(buildSyntheticRequest(c, c.req.valid('json')), c.env); + return handleCompileBatch(buildHonoRequest(c, c.req.valid('json')), c.env); }, ); @@ -888,7 +915,7 @@ routes.post( // deno-lint-ignore no-explicit-any zValidator('json', ValidateRequestSchema as any, zodValidationError), turnstileMiddleware(), - (c) => handleValidate(buildSyntheticRequest(c, c.req.valid('json')), c.env), + (c) => handleValidate(buildHonoRequest(c, c.req.valid('json')), c.env), ); // ── WebSocket ───────────────────────────────────────────────────────────────── @@ -944,7 +971,7 @@ routes.post( // deno-lint-ignore no-explicit-any const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); if (turnstileError) return turnstileError; - return handleConfigurationValidate(buildSyntheticRequest(c, c.req.valid('json')), c.env); + return handleConfigurationValidate(buildHonoRequest(c, c.req.valid('json')), c.env); }, ); @@ -1246,6 +1273,12 @@ 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. +app.all('/api/trpc/*', (c) => handleTrpcRequest(c)); + app.route('/api', routes); app.route('/', routes); diff --git a/worker/trpc/client.ts b/worker/trpc/client.ts new file mode 100644 index 00000000..3f44fa7a --- /dev/null +++ b/worker/trpc/client.ts @@ -0,0 +1,26 @@ +/** + * 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({ urls: ['...'] }); + */ + +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..5b621c8f --- /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: 'POST', + 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..5ed2807b --- /dev/null +++ b/worker/trpc/routers/v1/compile.router.ts @@ -0,0 +1,31 @@ +/** + * tRPC v1 compile router. + * + * v1.compile.json (mutation, authenticated) — accepts a CompileRequestSchema body + * and returns the compiled ruleset JSON. + */ + +import { protectedProcedure, router } from '../../init.ts'; +// deno-lint-ignore no-explicit-any +import { CompileRequestSchema } from '../../../../src/configuration/schemas.ts'; +import { handleCompileJson } from '../../../handlers/compile.ts'; + +/** Build a minimal synthetic POST Request from a JSON body string. */ +function makeSyntheticRequest(body: string): Request { + return new Request('https://worker.local', { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body, + }); +} + +export const compileRouter = router({ + json: protectedProcedure + // deno-lint-ignore no-explicit-any + .input(CompileRequestSchema as any) + .mutation(async ({ input, ctx }) => { + const req = makeSyntheticRequest(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..c87815ef --- /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 returns '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 — setup: define a test admin router', 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'); +}); From 4882542ade561e79d59b601317b2424550fb23dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 04:10:36 +0000 Subject: [PATCH 03/10] refactor: extract buildSyntheticRequest to worker/utils/synthetic-request.ts Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/b33ce10d-01bc-4189-b7b2-1993bf10a473 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- worker/hono-app.ts | 10 +++------- worker/trpc/routers/v1/compile.router.ts | 13 ++----------- worker/trpc/trpc.test.ts | 2 +- worker/utils/synthetic-request.ts | 18 ++++++++++++++++++ 4 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 worker/utils/synthetic-request.ts diff --git a/worker/hono-app.ts b/worker/hono-app.ts index 102f0a1b..92f14dce 100644 --- a/worker/hono-app.ts +++ b/worker/hono-app.ts @@ -278,14 +278,10 @@ function buildHonoRequest(c: AppContext, validatedBody: unknown): Request { * * Used by tRPC procedures to pass a Request object to existing handler * functions that expect the legacy `(Request, Env, ...)` signature. + * Re-exported from `./utils/synthetic-request.ts` so the public API surface + * remains on `hono-app.ts`. */ -export function buildSyntheticRequest(body: string): Request { - return new Request('https://worker.local', { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body, - }); -} +export { buildSyntheticRequest } from './utils/synthetic-request.ts'; // ============================================================================ // App setup diff --git a/worker/trpc/routers/v1/compile.router.ts b/worker/trpc/routers/v1/compile.router.ts index 5ed2807b..ace80046 100644 --- a/worker/trpc/routers/v1/compile.router.ts +++ b/worker/trpc/routers/v1/compile.router.ts @@ -6,25 +6,16 @@ */ import { protectedProcedure, router } from '../../init.ts'; -// deno-lint-ignore no-explicit-any import { CompileRequestSchema } from '../../../../src/configuration/schemas.ts'; import { handleCompileJson } from '../../../handlers/compile.ts'; - -/** Build a minimal synthetic POST Request from a JSON body string. */ -function makeSyntheticRequest(body: string): Request { - return new Request('https://worker.local', { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body, - }); -} +import { buildSyntheticRequest } from '../../../utils/synthetic-request.ts'; export const compileRouter = router({ json: protectedProcedure // deno-lint-ignore no-explicit-any .input(CompileRequestSchema as any) .mutation(async ({ input, ctx }) => { - const req = makeSyntheticRequest(JSON.stringify(input)); + 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/trpc.test.ts b/worker/trpc/trpc.test.ts index c87815ef..045c37dd 100644 --- a/worker/trpc/trpc.test.ts +++ b/worker/trpc/trpc.test.ts @@ -143,7 +143,7 @@ Deno.test('protectedProcedure — allows request when userId is set', async () = // ── adminProcedure auth enforcement ──────────────────────────────────────────── -Deno.test('adminProcedure — setup: define a test admin router', async () => { +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({ diff --git a/worker/utils/synthetic-request.ts b/worker/utils/synthetic-request.ts new file mode 100644 index 00000000..79dda0df --- /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, use the internal `buildHonoRequest` helper + * defined in `worker/hono-app.ts` instead. + */ +export function buildSyntheticRequest(body: string): Request { + return new Request('https://worker.local', { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body, + }); +} From a4784bcb7f1f93e2e4b09aca387d688d4cfad1e5 Mon Sep 17 00:00:00 2001 From: Jayson Knight Date: Sat, 28 Mar 2026 00:41:57 -0400 Subject: [PATCH 04/10] Update worker/trpc/handler.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- worker/trpc/handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker/trpc/handler.ts b/worker/trpc/handler.ts index 5b621c8f..878d41f3 100644 --- a/worker/trpc/handler.ts +++ b/worker/trpc/handler.ts @@ -41,7 +41,7 @@ export async function handleTrpcRequest(c: Context<{ Bindings: Env; Variables: A analytics?.trackSecurityEvent({ eventType: 'auth_failure', path: `/api/trpc/${path ?? ''}`, - method: 'POST', + method: c.req.method, clientIpHash: AnalyticsService.hashIp(ip), reason: error.code === 'UNAUTHORIZED' ? 'trpc_unauthorized' : 'trpc_forbidden', }); From 41de5425b946f0b4f76b65373c5ac661751523d4 Mon Sep 17 00:00:00 2001 From: Jayson Knight Date: Sat, 28 Mar 2026 00:42:19 -0400 Subject: [PATCH 05/10] Update worker/trpc/trpc.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- worker/trpc/trpc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker/trpc/trpc.test.ts b/worker/trpc/trpc.test.ts index 045c37dd..6287086d 100644 --- a/worker/trpc/trpc.test.ts +++ b/worker/trpc/trpc.test.ts @@ -91,7 +91,7 @@ Deno.test('v1.version.get — returns "unknown" when COMPILER_VERSION is absent' 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 returns 'unknown' + // Empty string is falsy — the router uses `||` so it falls back to 'unknown' assertEquals(result.version, 'unknown'); assertEquals(result.apiVersion, 'v1'); }); From 9da3c723ec122d24dfe09ff3637a8ab76a36cc51 Mon Sep 17 00:00:00 2001 From: Jayson Knight Date: Sat, 28 Mar 2026 00:42:34 -0400 Subject: [PATCH 06/10] Update docs/architecture/trpc.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/architecture/trpc.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/architecture/trpc.md b/docs/architecture/trpc.md index 065475ac..8cf1a52a 100644 --- a/docs/architecture/trpc.md +++ b/docs/architecture/trpc.md @@ -28,10 +28,19 @@ Returns `{ version, apiVersion }`. ## Angular client ```typescript -import { createTrpcClient } from '@worker/trpc/client.ts'; +import { createTrpcClient } from './trpc-client'; const client = createTrpcClient(environment.apiBaseUrl, () => authService.getToken()); -const result = await client.v1.compile.json.mutate({ urls: ['...'] }); +const result = await client.v1.compile.json.mutate({ + configuration: { + sources: [ + { + url: 'https://example.com/easylist.txt', + }, + ], + }, + preFetchedContent: {}, +}); ``` ## Adding a new procedure From 6917084b8da86ce3d00447032ba0759464a67356 Mon Sep 17 00:00:00 2001 From: Jayson Knight Date: Sat, 28 Mar 2026 00:42:51 -0400 Subject: [PATCH 07/10] Update worker/trpc/client.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- worker/trpc/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/worker/trpc/client.ts b/worker/trpc/client.ts index 3f44fa7a..f14ea98c 100644 --- a/worker/trpc/client.ts +++ b/worker/trpc/client.ts @@ -5,7 +5,11 @@ * 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({ urls: ['...'] }); + * const result = await client.v1.compile.json.mutate({ + * configuration: { + * sources: [{ url: 'https://example.com/filters.txt' }], + * }, + * }); */ import { createTRPCClient, httpBatchLink } from '@trpc/client'; From dd2852ddfb5c1f9ed52ba9bb468aab8f6156ab86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:03:35 +0000 Subject: [PATCH 08/10] chore: migrate tRPC deps to deno add (deno.lock managed) Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/dafe5bad-e683-4bd6-a16a-b350e24ed102 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- deno.json | 4 +--- deno.lock | 14 +++++++------- package.json | 2 ++ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/deno.json b/deno.json index 67e0d44d..a7f1cb01 100644 --- a/deno.json +++ b/deno.json @@ -125,9 +125,7 @@ "zod": "jsr:@zod/zod@^4.3.6", "hono": "npm:hono@^4.12.8", "@hono/zod-validator": "npm:@hono/zod-validator@^0.4.3", - "@hono/zod-openapi": "npm:@hono/zod-openapi@^1.2.2", - "@trpc/server": "npm:@trpc/server@^11.0.0", - "@trpc/client": "npm:@trpc/client@^11.0.0" + "@hono/zod-openapi": "npm:@hono/zod-openapi@^1.2.2" }, "lint": { "rules": { diff --git a/deno.lock b/deno.lock index 70a51732..28fe57ff 100644 --- a/deno.lock +++ b/deno.lock @@ -34,11 +34,11 @@ "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": "11.15.1_@trpc+server@11.15.1__typescript@6.0.2_typescript@6.0.2", - "npm:@trpc/server@11": "11.15.1_typescript@6.0.2", + "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___typescript@6.0.2__typescript@6.0.2_pg@8.13.3_prisma@7.5.0__typescript@6.0.2_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_typescript@6.0.2", + "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", @@ -1206,7 +1206,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___typescript@6.0.2__typescript@6.0.2_pg@8.13.3_prisma@7.5.0__typescript@6.0.2_typescript@6.0.2": { + "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", @@ -2945,8 +2945,6 @@ "npm:@prisma/adapter-pg@^7.5.0", "npm:@prisma/client@^7.5.0", "npm:@sentry/cloudflare@^10.43.0", - "npm:@trpc/client@11", - "npm:@trpc/server@11", "npm:agents@0.8", "npm:better-auth@^1.5.5", "npm:cloudflare@^5.2.0", @@ -2970,6 +2968,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/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", From ab330a4645e78d58fe04b65b43fa3f11d9c3e1f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 05:59:47 +0000 Subject: [PATCH 09/10] fix: update pnpm-lock.yaml for @trpc/client and @trpc/server deps Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/8913dd42-4a6f-46a8-9392-0369c745fba8 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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 From 3b4de559cc751e9225eccbcf27205e283aca912a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:16:14 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix(trpc):=20apply=20review=20comment=20f?= =?UTF-8?q?ixes=20=E2=80=94=20ZTA=20gate,=20rate-limit,=20header=20order,?= =?UTF-8?q?=20type=20inference,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/5fbd6aa3-5013-460b-8d40-82db88073b51 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- docs/architecture/trpc.md | 8 ++++-- worker/hono-app.ts | 33 ++++++++++++++++++++++-- worker/trpc/routers/v1/compile.router.ts | 18 +++++++++++-- worker/utils/synthetic-request.ts | 4 +-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/architecture/trpc.md b/docs/architecture/trpc.md index 8cf1a52a..194002f2 100644 --- a/docs/architecture/trpc.md +++ b/docs/architecture/trpc.md @@ -79,6 +79,10 @@ flowchart TD - `adminProcedure` additionally enforces `role === 'admin'` (returns `FORBIDDEN`). - Auth failures emit `AnalyticsService.trackSecurityEvent()` via the `onError` hook in `worker/trpc/handler.ts`. -- The global rate-limit middleware runs before all tRPC requests (shared with REST - endpoints). +- `/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/worker/hono-app.ts b/worker/hono-app.ts index fe91c2ee..dedc515b 100644 --- a/worker/hono-app.ts +++ b/worker/hono-app.ts @@ -50,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'; @@ -186,10 +187,10 @@ app.onError((err, c) => { // ── 0. Server-Timing middleware ─────────────────────────────────────────────── app.use('*', timing()); -// ── 0a. API versioning header — set on every response ──────────────────────── +// ── 0a. API versioning header — set on every response (including errors) ────── app.use('*', async (c, next) => { - await next(); c.header('X-API-Version', 'v1'); + await next(); }); // ── 1. Request metadata middleware ──────────────────────────────────────────── @@ -568,6 +569,34 @@ app.get('/api/openapi.json', (c) => { // 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); diff --git a/worker/trpc/routers/v1/compile.router.ts b/worker/trpc/routers/v1/compile.router.ts index ace80046..cf592441 100644 --- a/worker/trpc/routers/v1/compile.router.ts +++ b/worker/trpc/routers/v1/compile.router.ts @@ -5,6 +5,8 @@ * 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'; @@ -12,8 +14,20 @@ import { buildSyntheticRequest } from '../../../utils/synthetic-request.ts'; export const compileRouter = router({ json: protectedProcedure - // deno-lint-ignore no-explicit-any - .input(CompileRequestSchema as any) + // 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); diff --git a/worker/utils/synthetic-request.ts b/worker/utils/synthetic-request.ts index 79dda0df..b6313c8e 100644 --- a/worker/utils/synthetic-request.ts +++ b/worker/utils/synthetic-request.ts @@ -6,8 +6,8 @@ * (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, use the internal `buildHonoRequest` helper - * defined in `worker/hono-app.ts` instead. + * `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', {