From bb4306f3d14e07ceb9c92c99e7260b5bc9321176 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 03:51:12 +0000 Subject: [PATCH 1/3] Initial plan From 76c759ed2a443bb61a464211e4f0921e7fcc0693 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 04:21:15 +0000 Subject: [PATCH 2/3] =?UTF-8?q?refactor(worker):=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20split=20hono-app.ts=20into=20domain=20route=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract all route handlers from hono-app.ts (1279 lines) into 10 domain-scoped route files under worker/routes/ - Remove app.route('/', routes) bare-path double-mount — /api is canonical - Add scripts/lint-route-order.ts CI guard for route ordering invariants - Add lint:routes task to deno.json and CI step to ci.yml - Update docs/architecture/hono-routing.md with Phase 4 section - Update hono-app.test.ts bare-path tests to use /api/ prefix Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/86d24908-49bb-4286-87b5-ee18aedaa606 Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- .github/workflows/ci.yml | 3 + deno.json | 1 + deno.lock | 5 +- docs/architecture/hono-routing.md | 80 ++- scripts/lint-route-order.ts | 148 +++++ worker/hono-app.test.ts | 47 +- worker/hono-app.ts | 839 ++------------------------ worker/routes/admin.routes.ts | 192 ++++++ worker/routes/api-keys.routes.ts | 85 +++ worker/routes/browser.routes.ts | 15 + worker/routes/compile.routes.ts | 210 +++++++ worker/routes/configuration.routes.ts | 67 ++ worker/routes/index.ts | 18 + worker/routes/monitoring.routes.ts | 67 ++ worker/routes/queue.routes.ts | 22 + worker/routes/rules.routes.ts | 67 ++ worker/routes/shared.ts | 103 ++++ worker/routes/webhook.routes.ts | 34 ++ worker/routes/workflow.routes.ts | 23 + 19 files changed, 1193 insertions(+), 833 deletions(-) create mode 100644 scripts/lint-route-order.ts create mode 100644 worker/routes/admin.routes.ts create mode 100644 worker/routes/api-keys.routes.ts create mode 100644 worker/routes/browser.routes.ts create mode 100644 worker/routes/compile.routes.ts create mode 100644 worker/routes/configuration.routes.ts create mode 100644 worker/routes/index.ts create mode 100644 worker/routes/monitoring.routes.ts create mode 100644 worker/routes/queue.routes.ts create mode 100644 worker/routes/rules.routes.ts create mode 100644 worker/routes/shared.ts create mode 100644 worker/routes/webhook.routes.ts create mode 100644 worker/routes/workflow.routes.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6847a24d4..32a4339b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,9 @@ jobs: - name: Format check run: deno fmt --check + - name: Route-order lint + run: deno task lint:routes + typecheck: name: Type Check runs-on: ubuntu-latest diff --git a/deno.json b/deno.json index 5c2f36f2b..b6ebcba0a 100644 --- a/deno.json +++ b/deno.json @@ -32,6 +32,7 @@ "bench:transformations": "deno bench src/transformations/*.bench.ts", "bench:json": "deno bench --allow-read --allow-write --allow-net --allow-env --json", "lint": "deno lint", + "lint:routes": "deno run --allow-read scripts/lint-route-order.ts", "fmt": "deno fmt", "fmt:check": "deno fmt --check", "check": "deno task check:src && deno task check:worker", diff --git a/deno.lock b/deno.lock index b2c4966f5..0292e5eab 100644 --- a/deno.lock +++ b/deno.lock @@ -14,6 +14,7 @@ "jsr:@std/fs@^1.0.22": "1.0.23", "jsr:@std/fs@^1.0.23": "1.0.23", "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/path@*": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/testing@^1.0.17": "1.0.17", "jsr:@std/yaml@*": "1.0.10", @@ -85,7 +86,7 @@ "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ "jsr:@std/internal", - "jsr:@std/path" + "jsr:@std/path@^1.1.4" ] }, "@std/internal@1.0.12": { @@ -105,7 +106,7 @@ "jsr:@std/data-structures", "jsr:@std/fs@^1.0.22", "jsr:@std/internal", - "jsr:@std/path" + "jsr:@std/path@^1.1.4" ] }, "@std/yaml@1.0.10": { diff --git a/docs/architecture/hono-routing.md b/docs/architecture/hono-routing.md index 874726ed2..be5e37141 100644 --- a/docs/architecture/hono-routing.md +++ b/docs/architecture/hono-routing.md @@ -58,17 +58,18 @@ These variables are set by middleware and available to all route handlers via `c The frontend uses `API_BASE_URL = '/api'`, so all API requests from the frontend arrive as `/api/compile`, `/api/rules`, etc. -Hono's `app.route()` is used to mount the same `routes` sub-app under both `/` and `/api`: +Prior to Phase 4, Hono's `app.route()` was used to mount the `routes` sub-app under +**both** `/` and `/api`. The bare-path mount was removed in Phase 4 (domain route split) +to eliminate the double-execution side-effect and simplify the routing surface: ```typescript -// /api is mounted first — ensures correct prefix-stripping for /api/* requests -// before the root-mount sub-app intercepts them as unrecognised paths. +// Phase 4: /api is the canonical base path — bare-path mount removed. app.route('/api', routes); -app.route('/', routes); +// app.route('/', routes); ← removed in Phase 4 ``` -This means `/compile` and `/api/compile` both reach the same handler. No path-stripping -logic is needed in route handlers. +`/api` is the only canonical base path. Bare-path requests (`/compile`, `/health`, etc.) +are no longer served. --- @@ -209,8 +210,67 @@ async (c) => { --- -## Phase 3 Roadmap +--- + +## Phase 4 — Domain Route Modules (complete) + +Phase 4 split the `worker/hono-app.ts` monolith into domain-scoped route files under +`worker/routes/`. Each file exports a single `OpenAPIHono` sub-app instance that is +mounted on the `routes` sub-app in `hono-app.ts`. + +### New file layout + +``` +worker/ + hono-app.ts ← app setup + middleware only; imports route modules + routes/ + compile.routes.ts ← /compile/*, /validate, /ast/parse, /ws/compile, /validate-rule + rules.routes.ts ← /rules/* + queue.routes.ts ← /queue/* + configuration.routes.ts ← /configuration/* + admin.routes.ts ← /admin/* (users, neon, agents, auth-config, usage, storage) + monitoring.routes.ts ← /health/*, /metrics/*, /container/status + api-keys.routes.ts ← /keys/* + webhook.routes.ts ← /notify + workflow.routes.ts ← /workflow/* + browser.routes.ts ← /browser/* (stub — routes added in a future PR) + index.ts ← barrel: exports all sub-apps + shared.ts ← shared types (AppContext) and helpers used by route files +``` + +### Mount strategy + +Each domain sub-app is mounted on the `routes` sub-app at the root path: + +```typescript +routes.route('/', compileRoutes); +routes.route('/', rulesRoutes); +routes.route('/', queueRoutes); +// ... etc +``` + +The `routes` sub-app itself is mounted only at `/api`: + +```typescript +app.route('/api', routes); +// app.route('/', routes); ← bare-path double-mount removed in Phase 4 +``` + +### Middleware inheritance + +All middleware registered on `routes` (logger, compress with `NO_COMPRESS_PATHS` +exclusion, ZTA permission check) still wraps every sub-app route, because the +sub-apps are mounted on `routes` — not directly on `app`. No middleware changes +were needed. + +### CI route-order guard + +`scripts/lint-route-order.ts` validates four invariants on every CI run: + +1. `timing()` is the first `app.use()` call +2. The Better Auth `/api/auth/*` handler is registered before `agentRouter` +3. `app.route('/', routes)` is absent (no bare-path double-mount) +4. The compress middleware uses the `NO_COMPRESS_PATHS` exclusion pattern + +Run manually with: `deno task lint:routes` -- Generate OpenAPI spec from route + schema definitions using `hono/openapi` -- Generate a type-safe RPC client with `hono/client` for the frontend -- Extend `zValidator` to additional endpoints (e.g. `/configuration/validate`, `/validate-rule`) diff --git a/scripts/lint-route-order.ts b/scripts/lint-route-order.ts new file mode 100644 index 000000000..4d978c8ee --- /dev/null +++ b/scripts/lint-route-order.ts @@ -0,0 +1,148 @@ +/** + * CI lint guard — validates route-ordering invariants in `worker/hono-app.ts`. + * + * Checks: + * 1. `timing()` appears before all other `app.use()` calls. + * 2. `app.on(['POST', 'GET'], '/api/auth/*', ...)` appears before + * `app.route('/', agentRouter)` (Better Auth before agent routing). + * 3. `app.route('/api', routes)` is NOT preceded by `app.route('/', routes)` + * (double-mount guard — bare `/` mount was removed in Phase 4). + * 4. The compress middleware in the `routes` sub-app uses the `NO_COMPRESS_PATHS` + * exclusion pattern (not a bare `compress()` wildcard). + * + * Usage: + * deno run --allow-read scripts/lint-route-order.ts + * + * Exit codes: + * 0 — all checks pass + * 1 — one or more checks failed (descriptive messages printed to stderr) + */ + +import { join } from 'jsr:@std/path@^1.1.4'; + +const HONO_APP_PATH = join(import.meta.dirname ?? '.', '..', 'worker', 'hono-app.ts'); + +// Read the file +let src: string; +try { + src = await Deno.readTextFile(HONO_APP_PATH); +} catch (e) { + console.error(`[lint-route-order] ERROR: Cannot read ${HONO_APP_PATH}:`, e); + Deno.exit(1); +} + +// Strip single-line and multi-line comments so we don't accidentally match +// commented-out code. This is intentionally lightweight — it does not handle +// every edge case, but is sufficient for the ordered-invariant checks below. +const srcNoComments = src + .replace(/\/\*[\s\S]*?\*\//g, '') // block comments + .replace(/\/\/.*/g, ''); // line comments + +let passed = true; + +// ── Check 1: timing() is the FIRST app.use() call ──────────────────────────── +// We look for the position of the first `app.use(` call and the position of +// `timing()` inside it. + +const firstAppUseIdx = srcNoComments.indexOf('app.use('); +const timingCallIdx = srcNoComments.indexOf('timing()'); + +if (timingCallIdx === -1) { + console.error('[lint-route-order] FAIL #1: `timing()` call not found in hono-app.ts'); + passed = false; +} else if (firstAppUseIdx === -1) { + console.error('[lint-route-order] FAIL #1: No `app.use(` calls found in hono-app.ts'); + passed = false; +} else { + // The timing() middleware is registered as `app.use('*', timing())`. + // The firstAppUseIdx should be <= timingCallIdx (timing is first or included in the first call). + const timingAppUsePattern = /app\.use\s*\([^)]*timing\s*\(\)/; + const timingAppUseMatch = timingAppUsePattern.exec(srcNoComments); + if (!timingAppUseMatch) { + console.error('[lint-route-order] FAIL #1: `app.use(... timing() ...)` pattern not found'); + passed = false; + } else { + const timingAppUseIdx = timingAppUseMatch.index; + if (timingAppUseIdx > firstAppUseIdx) { + console.error( + `[lint-route-order] FAIL #1: timing() is not the first app.use() call.\n` + + ` First app.use() at char ${firstAppUseIdx}, timing() app.use() at char ${timingAppUseIdx}`, + ); + passed = false; + } else { + console.log('[lint-route-order] PASS #1: timing() is first app.use()'); + } + } +} + +// ── Check 2: Better Auth handler before agent router ───────────────────────── +// `app.on(['POST', 'GET'], '/api/auth/*', ...)` must appear before +// `app.route('/', agentRouter)`. + +const betterAuthIdx = srcNoComments.indexOf("app.on(['POST', 'GET'], '/api/auth/*'"); +const agentRouterIdx = srcNoComments.indexOf("app.route('/', agentRouter)"); + +if (betterAuthIdx === -1) { + console.error("[lint-route-order] FAIL #2: `app.on(['POST', 'GET'], '/api/auth/*')` not found"); + passed = false; +} else if (agentRouterIdx === -1) { + console.error("[lint-route-order] FAIL #2: `app.route('/', agentRouter)` not found"); + passed = false; +} else if (betterAuthIdx > agentRouterIdx) { + console.error( + `[lint-route-order] FAIL #2: Better Auth /api/auth/* handler appears AFTER agentRouter mount.\n` + + ` Better Auth at char ${betterAuthIdx}, agentRouter at char ${agentRouterIdx}`, + ); + passed = false; +} else { + console.log('[lint-route-order] PASS #2: Better Auth registered before agentRouter'); +} + +// ── Check 3: No bare-path double-mount guard ────────────────────────────────── +// `app.route('/', routes)` must NOT appear anywhere in the file (the bare-path +// double-mount was intentionally removed in Phase 4). + +// We specifically look for `app.route('/', routes)` (where `routes` is the local +// business-routes sub-app, not `agentRouter`). Be precise to avoid false positives. +const doubleMount = /app\.route\s*\(\s*'\/'\s*,\s*routes\s*\)/.exec(srcNoComments); +if (doubleMount) { + console.error( + `[lint-route-order] FAIL #3: Bare-path double-mount detected: app.route('/', routes)\n` + + ` This was removed in Phase 4. Only app.route('/api', routes) is allowed.\n` + + ` Found at char ${doubleMount.index}`, + ); + passed = false; +} else { + console.log("[lint-route-order] PASS #3: No bare-path double-mount (app.route('/', routes) not present)"); +} + +// ── Check 4: Compress middleware uses NO_COMPRESS_PATHS exclusion ───────────── +// The `routes` sub-app compress middleware must reference `NO_COMPRESS_PATHS` +// (not a bare `routes.use('*', compress())` call). + +const bareCompressPattern = /routes\.use\s*\(\s*'\*'\s*,\s*compress\s*\(\s*\)\s*\)/; +if (bareCompressPattern.test(srcNoComments)) { + console.error( + "[lint-route-order] FAIL #4: Bare compress() wildcard detected: routes.use('*', compress()).\n" + + ' Use the NO_COMPRESS_PATHS exclusion pattern instead to skip compression on health/metrics endpoints.', + ); + passed = false; +} else if (!srcNoComments.includes('NO_COMPRESS_PATHS')) { + console.error( + '[lint-route-order] FAIL #4: NO_COMPRESS_PATHS constant not found in hono-app.ts.\n' + + ' The compress middleware must use this exclusion set to skip /health and /metrics routes.', + ); + passed = false; +} else { + console.log('[lint-route-order] PASS #4: Compress middleware uses NO_COMPRESS_PATHS exclusion'); +} + +// ── Summary ─────────────────────────────────────────────────────────────────── + +if (passed) { + console.log('\n✅ All route-order checks passed.'); + Deno.exit(0); +} else { + console.error('\n❌ One or more route-order checks failed. See errors above.'); + Deno.exit(1); +} diff --git a/worker/hono-app.test.ts b/worker/hono-app.test.ts index 06b42b29a..93d203cda 100644 --- a/worker/hono-app.test.ts +++ b/worker/hono-app.test.ts @@ -32,11 +32,6 @@ async function fetch( // ── Tests ───────────────────────────────────────────────────────────────────── -Deno.test('GET /health returns 200', async () => { - const res = await fetch('/health'); - assertEquals(res.status, 200); -}); - Deno.test('GET /api/health returns 200 (via /api prefix)', async () => { const res = await fetch('/api/health'); assertEquals(res.status, 200); @@ -51,20 +46,15 @@ Deno.test('GET /api/version returns version info (pre-auth meta route)', async ( assertEquals(typeof body.version, 'string'); }); -Deno.test('GET /rules returns 401 for anonymous users', async () => { - const res = await fetch('/rules'); - assertEquals(res.status, 401); -}); - Deno.test('GET /api/rules returns 401 for anonymous users (via /api prefix)', async () => { const res = await fetch('/api/rules'); assertEquals(res.status, 401); }); -Deno.test('POST /compile returns 401 for anonymous users (Free tier required)', async () => { +Deno.test('POST /api/compile returns 401 for anonymous users (Free tier required)', async () => { // /compile requires UserTier.Free per the route-permission registry. // Anonymous requests are blocked before the rate-limit check is reached. - const res = await fetch('/compile', { + const res = await fetch('/api/compile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rules: ['||example.com^'] }), @@ -74,7 +64,7 @@ Deno.test('POST /compile returns 401 for anonymous users (Free tier required)', assertEquals(body.success, false); }); -Deno.test('GET /configuration/defaults rate-limits anonymous users when quota is exhausted', async () => { +Deno.test('GET /api/configuration/defaults rate-limits anonymous users when quota is exhausted', async () => { // /configuration/defaults is accessible to UserTier.Anonymous, so rate limiting // is enforced before the handler runs — ideal for verifying the 429 path. const store = new Map(); @@ -82,7 +72,7 @@ Deno.test('GET /configuration/defaults rate-limits anonymous users when quota is store.set('ratelimit:ip:unknown', JSON.stringify({ count: 9999, resetAt: now + 60_000 })); const env = makeEnv({ RATE_LIMIT: makeInMemoryKv(store) }); - const res = await fetch('/configuration/defaults', { env }); + const res = await fetch('/api/configuration/defaults', { env }); assertEquals(res.status, 429); const body = await res.json() as Record; assertEquals(body.success, false); @@ -117,8 +107,8 @@ Deno.test('OPTIONS preflight returns 200 or 204', async () => { assertEquals(res.status === 200 || res.status === 204, true); }); -Deno.test('GET /docs redirects to DOCS_SITE_URL', async () => { - const res = await fetch('/docs', { redirect: 'manual' }); +Deno.test('GET /api/docs redirects to DOCS_SITE_URL', async () => { + const res = await fetch('/api/docs', { redirect: 'manual' }); assertEquals(res.status, 302); const location = res.headers.get('Location'); assertEquals(typeof location, 'string'); @@ -130,12 +120,6 @@ Deno.test('404 for unknown routes', async () => { assertEquals(res.status !== 200 && res.status !== 500, true); }); -Deno.test('GET /metrics returns 200', async () => { - const env = makeEnv({ METRICS: makeInMemoryKv(new Map()) }); - const res = await fetch('/metrics', { env }); - assertEquals(res.status, 200); -}); - // ── Monitoring endpoint pre-auth bypass (#1370) ─────────────────────────────── // /health and /metrics are Anonymous-tier per ROUTE_PERMISSION_REGISTRY. // /queue/stats and /queue/history remain Free-tier (authenticated only). @@ -159,12 +143,6 @@ Deno.test('GET /api/queue/stats returns 401 for anonymous users (Free tier requi assertEquals(res.status, 401); }); -Deno.test('GET /queue/stats returns 401 for anonymous users (bare path, Free tier required)', async () => { - // Same as above but via the bare-path mount at /. - const res = await fetch('/queue/stats'); - assertEquals(res.status, 401); -}); - Deno.test('GET /api/openapi.json is publicly accessible and returns valid spec or 501 when not yet configured', async () => { const res = await fetch('/api/openapi.json'); // The endpoint is publicly accessible (no auth required) @@ -187,20 +165,15 @@ Deno.test('GET /api/openapi.json is publicly accessible and returns valid spec o // ── Admin session revocation — DELETE /admin/users/:id/sessions (#1275) ────── -Deno.test('DELETE /admin/users/:id/sessions returns 401 for anonymous user', async () => { - const res = await fetch('/admin/users/user_123/sessions', { method: 'DELETE' }); - assertEquals(res.status, 401); -}); - Deno.test('DELETE /api/admin/users/:id/sessions returns 401 for anonymous (/api prefix)', async () => { const res = await fetch('/api/admin/users/user_123/sessions', { method: 'DELETE' }); assertEquals(res.status, 401); }); -Deno.test('DELETE /admin/users/:id/sessions returns 401 when Bearer token is invalid', async () => { +Deno.test('DELETE /api/admin/users/:id/sessions returns 401 when Bearer token is invalid', async () => { // An invalid Bearer token is rejected by the auth chain → 401. // This also verifies the route is registered (not 404) and the auth chain runs. - const res = await fetch('/admin/users/user_123/sessions', { + const res = await fetch('/api/admin/users/user_123/sessions', { method: 'DELETE', headers: { 'Authorization': 'Bearer invalid_token' }, }); @@ -208,8 +181,8 @@ Deno.test('DELETE /admin/users/:id/sessions returns 401 when Bearer token is inv assertEquals(res.status, 401); }); -Deno.test('DELETE /admin/users/:id/sessions route is registered (not 404)', async () => { - const res = await fetch('/admin/users/user_123/sessions', { method: 'DELETE' }); +Deno.test('DELETE /api/admin/users/:id/sessions route is registered (not 404)', async () => { + const res = await fetch('/api/admin/users/user_123/sessions', { method: 'DELETE' }); // Should be 401 (unauthorized) not 404 (not found) assertEquals(res.status !== 404, true); }); diff --git a/worker/hono-app.ts b/worker/hono-app.ts index 93e3f226b..dcab856aa 100644 --- a/worker/hono-app.ts +++ b/worker/hono-app.ts @@ -10,15 +10,11 @@ * * 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` * - `timing()` middleware adds `Server-Timing` headers to every response - * - `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/* - * - `logger()` on the `routes` sub-app for standardized request/response logging — scoped to business routes, never touches /api/auth/* - * - `cache()` middleware on /configuration/defaults (300s), /api/version (3600s), /api/schemas (3600s) - * - Cache-Control headers on /health (30 s) and /configuration/defaults (300 s) + * - `compress()` on the `routes` sub-app for automatic response compression (gzip/deflate) + * - `logger()` on the `routes` sub-app for standardized request/response logging * - `GET /api/openapi.json` serves the auto-generated OpenAPI 3.0 spec * - `AppType` export enables `hc()` typed RPC client in Angular * @@ -26,6 +22,7 @@ * @see docs/architecture/hono-rpc-client.md — typed RPC client pattern * @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 */ /// @@ -34,15 +31,13 @@ import { cors } from 'hono/cors'; import { secureHeaders } from 'hono/secure-headers'; import type { Context } from 'hono'; import { endTime, startTime, timing } from 'hono/timing'; -import { etag } from 'hono/etag'; import { prettyJSON } from 'hono/pretty-json'; import { compress } from 'hono/compress'; import { logger } from 'hono/logger'; -import { cache } from 'hono/cache'; import { OpenAPIHono } from '@hono/zod-openapi'; // Types -import type { Env, IAuthContext } from './types.ts'; +import type { Env } from './types.ts'; import { ANONYMOUS_AUTH_CONTEXT } from './types.ts'; // Services @@ -50,11 +45,9 @@ import { AnalyticsService } from '../src/services/AnalyticsService.ts'; import { WORKER_DEFAULTS } from '../src/config/defaults.ts'; // Middleware -import { checkRateLimitTiered, verifyTurnstileToken } from './middleware/index.ts'; +import { checkRateLimitTiered } from './middleware/index.ts'; import { authenticateRequestUnified } from './middleware/auth.ts'; -import { bodySizeMiddleware, rateLimitMiddleware, requireAuthMiddleware, turnstileMiddleware } from './middleware/hono-middleware.ts'; import { BetterAuthProvider } from './middleware/better-auth-provider.ts'; -import { verifyCfAccessJwt } from './middleware/cf-access.ts'; // Auth import { createAuth } from './lib/auth.ts'; @@ -68,78 +61,28 @@ import { checkRoutePermission } from './utils/route-permissions.ts'; import { checkUserApiAccess } from './utils/user-access.ts'; import { trackApiUsage } from './utils/api-usage.ts'; import { isPublicEndpoint, matchOrigin } from './utils/cors.ts'; -import { JsonResponse } from './utils/response.ts'; - -// Handlers (eagerly imported — on the hot path) -import { - handleASTParseRequest, - handleCompileAsync, - handleCompileBatch, - handleCompileBatchAsync, - handleCompileJson, - handleCompileStream, - handleValidate, -} from './handlers/compile.ts'; -import { handleValidateRule } from './handlers/validate-rule.ts'; -import { handleRulesCreate, handleRulesDelete, handleRulesGet, handleRulesList, handleRulesUpdate } from './handlers/rules.ts'; -import { handleNotify } from './handlers/webhook.ts'; -import { handleCreateApiKey, handleListApiKeys, handleRevokeApiKey, handleUpdateApiKey } from './handlers/api-keys.ts'; -import { handleAdminBanUser, handleAdminDeleteUser, handleAdminGetUser, handleAdminListUsers, handleAdminUnbanUser, handleAdminUpdateUser } from './handlers/admin-users.ts'; -import { handleAdminAuthConfig } from './handlers/auth-config.ts'; + +// Handlers (pre-auth meta routes — eagerly imported) import { handleAuthProviders } from './handlers/auth-providers.ts'; -import { handleAdminGetUserUsage } from './handlers/admin-usage.ts'; -import { - handleAdminNeonCreateBranch, - handleAdminNeonDeleteBranch, - handleAdminNeonGetBranch, - handleAdminNeonGetProject, - handleAdminNeonListBranches, - handleAdminNeonListDatabases, - handleAdminNeonListEndpoints, - handleAdminNeonQuery, -} from './handlers/admin-neon.ts'; -import { handleAdminGetAgentSession, handleAdminListAgentAuditLog, handleAdminListAgentSessions, handleAdminTerminateAgentSession } from './handlers/admin-agents.ts'; -import { handlePrometheusMetrics } from './handlers/prometheus-metrics.ts'; -import { handleMetrics } from './handlers/metrics.ts'; -import { handleConfigurationDefaults, handleConfigurationResolve, handleConfigurationValidate } from './handlers/configuration.ts'; - -import { zValidator } from '@hono/zod-validator'; -import { BatchRequestAsyncSchema, BatchRequestSyncSchema, CompileRequestSchema } from '../src/configuration/schemas.ts'; -import { ConfigurationValidateRequestSchema, ResolveRequestSchema } from './handlers/configuration.ts'; -import { - AdminBanUserSchema, - AdminNeonCreateBranchSchema, - AdminNeonQuerySchema, - AdminUnbanUserSchema, - AdminUpdateUserSchema, - AstParseRequestSchema, - CreateApiKeyRequestSchema, - RuleSetCreateSchema, - RuleSetUpdateSchema, - UpdateApiKeyRequestSchema, - ValidateRequestSchema, - ValidateRuleRequestSchema, - WebhookNotifyRequestSchema, -} from './schemas.ts'; // Agent routing (authenticated) import { agentRouter } from './agents/index.ts'; -import { handleWebSocketUpgrade } from './websocket.ts'; - -// ============================================================================ -// Types -// ============================================================================ -/** - * Hono context variables set by middleware and available in route handlers. - */ -export interface Variables { - authContext: IAuthContext; - analytics: AnalyticsService; - requestId: string; - ip: string; - isSSR: boolean; // true when the request originated from the SSR Worker via env.API.fetch() -} +// Route modules +import { adminRoutes } from './routes/admin.routes.ts'; +import { apiKeysRoutes } from './routes/api-keys.routes.ts'; +import { browserRoutes } from './routes/browser.routes.ts'; +import { compileRoutes } from './routes/compile.routes.ts'; +import { configurationRoutes } from './routes/configuration.routes.ts'; +import { monitoringRoutes } from './routes/monitoring.routes.ts'; +import { queueRoutes } from './routes/queue.routes.ts'; +import { rulesRoutes } from './routes/rules.routes.ts'; +import { webhookRoutes } from './routes/webhook.routes.ts'; +import { workflowRoutes } from './routes/workflow.routes.ts'; + +// Shared types — re-exported for backward compatibility +export type { Variables } from './routes/shared.ts'; +import type { Variables } from './routes/shared.ts'; // ============================================================================ // Constants @@ -148,13 +91,6 @@ export interface Variables { const RATE_LIMIT_WINDOW = WORKER_DEFAULTS.RATE_LIMIT_WINDOW_SECONDS; // Dashboard monitoring endpoints — read-only, no PII, publicly accessible by design. -// Used by Angular MetricsStore (unauthenticated SWR polling). -// Anonymous-tier rate limiting (ANONYMOUS_AUTH_CONTEXT) is still applied via -// `checkRateLimitTiered`, so abuse is throttled despite the auth bypass. -// -// NOTE: /api/queue/stats and /api/queue/history are intentionally excluded because -// they require UserTier.Free per ROUTE_PERMISSION_REGISTRY. Including them here -// would force ANONYMOUS_AUTH_CONTEXT and break them for authenticated callers. const MONITORING_API_PATHS = [ '/api/health', '/api/health/latest', @@ -174,9 +110,10 @@ const PRE_AUTH_PATHS = [ ...MONITORING_API_PATHS, ] as const; -// Bare-path variants of MONITORING_API_PATHS — needed when the `routes` sub-app -// is mounted at `/` (in addition to `/api`), so that requests arriving as -// `/health`, `/metrics`, etc. also bypass auth. +// Bare-path variants of MONITORING_API_PATHS — retained for request matching +// in the unified auth middleware. The bare-path double-mount (`app.route('/', routes)`) +// was removed in Phase 4; this constant is kept so that any cached proxy/CDN +// requests arriving without the /api prefix continue to bypass auth correctly. const MONITORING_BARE_PATHS = new Set(MONITORING_API_PATHS.map((p) => p.slice(4))); // ============================================================================ @@ -187,94 +124,15 @@ type AppContext = Context<{ Bindings: Env; Variables: Variables }>; /** * Normalise the route path for permission/ZTA checks inside the `routes` sub-app. - * - * Hono *route handlers* receive the prefix-stripped path (e.g. `/health` when the - * sub-app is mounted under `/api`), but *middleware* registered with `routes.use()` - * still sees the original request path (e.g. `/api/health`). This helper strips - * the `/api` prefix so both layers always work with the canonical path that matches - * entries in `ROUTE_PERMISSION_REGISTRY`. */ function routesPath(c: AppContext): string { const p = c.req.path; return p.startsWith('/api/') ? p.slice(4) : p; } -/** - * Shared zValidator error callback — returns a 422 JSON response when Zod - * validation fails. Used across all `zValidator('json', ...)` calls. - * - * @hono/zod-validator types against npm:zod while this project uses - * jsr:@zod/zod — both are Zod v4 with identical runtime APIs; the cast - * to `any` avoids a module-identity mismatch that is type-only. - * - * When validation fails, `result.error` is a `ZodError` instance from - * jsr:@zod/zod — typed as `unknown` here to bridge the module identity gap, - * but serialised as-is into the 422 response body so callers receive full - * structured error details. - * - * @example - * ```ts - * zValidator('json', SomeSchema as any, zodValidationError) - * ``` - */ -// deno-lint-ignore no-explicit-any -function zodValidationError(result: { success: boolean; error?: unknown }, c: AppContext): Response | void { - if (!result.success) { - return c.json({ success: false, error: 'Invalid request body', details: result.error }, 422); - } -} - -/** - * Verify a Turnstile token extracted from an already-validated JSON body. - * - * Must be called AFTER `zValidator` has consumed the body stream (when the - * `turnstileToken` field is accessed via `c.req.valid('json')`). - * - * Returns the error `Response` (403) on rejection, or `null` when the - * Turnstile check passes (or when Turnstile is not configured). - */ -async function verifyTurnstileInline(c: AppContext, token: string): Promise { - if (!c.env.TURNSTILE_SECRET_KEY) return null; - const tsResult = await verifyTurnstileToken(c.env, token, c.get('ip')); - if (!tsResult.success) { - c.get('analytics').trackSecurityEvent({ - eventType: 'turnstile_rejection', - path: c.req.path, - method: c.req.method, - clientIpHash: AnalyticsService.hashIp(c.get('ip')), - tier: c.get('authContext').tier, - reason: tsResult.error ?? 'turnstile_verification_failed', - }); - return c.json({ success: false, error: tsResult.error ?? 'Turnstile verification failed' }, 403); - } - return null; -} - -/** - * Reconstruct a synthetic `Request` from a validated body. - * - * When `zValidator` consumes the original body stream, the existing handler - * functions (which accept a `Request`) cannot re-read `c.req.raw`. This - * helper creates a new `Request` that re-serialises the validated body so the - * handlers can continue using their existing `request.json()` API. - */ -function buildSyntheticRequest(c: AppContext, validatedBody: unknown): Request { - return new Request(c.req.url, { - method: 'POST', - headers: c.req.raw.headers, - body: JSON.stringify(validatedBody), - }); -} - -// ============================================================================ -// App setup -// ============================================================================ - /** * Applies CORS headers to an error response using the same allowlist-based - * policy as the CORS middleware. Called from `app.onError()` because the CORS - * middleware runs as a regular handler and has not yet executed when the global - * error handler fires. + * policy as the CORS middleware. */ function applyErrorCorsHeaders(c: AppContext): void { const origin = c.req.header('Origin'); @@ -288,14 +146,15 @@ function applyErrorCorsHeaders(c: AppContext): void { c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); } +// ============================================================================ +// App setup +// ============================================================================ + export const app = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); -// ── Global error handler — catches unhandled exceptions in all routes ───────── +// ── Global error handler ───────────────────────────────────────────────────── app.onError((err, c) => { const requestId = c.get('requestId') ?? 'unknown'; - - // Normalize error details — handle non-Error throwables gracefully and - // preserve stack traces so production incidents can be traced in logs. let errorDetails: string; if (err instanceof Error) { errorDetails = err.stack || err.message || String(err); @@ -310,16 +169,14 @@ app.onError((err, c) => { } // deno-lint-ignore no-console console.error(`[${requestId}] Unhandled error on ${c.req.method} ${c.req.path}:`, errorDetails); - applyErrorCorsHeaders(c); - return c.json( { success: false, error: 'Internal server error', requestId }, 500, ); }); -// ── 0. Server-Timing middleware (must be first to wrap all operations) ──────── +// ── 0. Server-Timing middleware ─────────────────────────────────────────────── app.use('*', timing()); // ── 1. Request metadata middleware ──────────────────────────────────────────── @@ -331,33 +188,15 @@ app.use('*', async (c, next) => { }); // ── 1a. SSR origin detection ────────────────────────────────────────────────── -// Identifies requests forwarded internally from the adblock-frontend -// SSR Worker via env.API.fetch(). These are trusted internal calls — Turnstile -// and rate limiting are not applicable to them. app.use('*', async (c, next) => { c.set('isSSR', c.req.header('CF-Worker-Source') === 'ssr'); await next(); }); -// ── 1b. Better Auth route handler ────────────────────────────────────────────── -// Better Auth handles its own routes (sign-up, sign-in, sign-out, get-session, -// etc.) — these must bypass unified auth because they CREATE sessions rather -// than verifying existing ones. -// -// A 10s timeout guard is applied here (mirroring BetterAuthProvider.verifyToken) -// so that a hung Hyperdrive/Prisma call cannot stall the Worker CPU indefinitely. -// AbortController.abort() is called on timeout to signal cancellation to the -// underlying fetch plumbing used by Better Auth / Prisma. -// -// IMPORTANT: This handler is registered BEFORE the global logger() and compress() -// middleware to avoid interfering with Better Auth's response handling. Better Auth -// returns responses directly without calling next(), and applying compression/logging -// middleware before this handler can cause response stream conflicts. +// ── 1b. Better Auth route handler ───────────────────────────────────────────── app.on(['POST', 'GET'], '/api/auth/*', async (c) => { if (!c.env.BETTER_AUTH_SECRET) return c.notFound(); if (!c.env.HYPERDRIVE) { - // Misconfigured deployment: Hyperdrive (Neon PostgreSQL) binding is missing. - // Better Auth uses PostgreSQL via Hyperdrive, not D1. return c.json({ error: 'Authentication service is temporarily unavailable' }, 503); } const url = new URL(c.req.url); @@ -365,8 +204,6 @@ app.on(['POST', 'GET'], '/api/auth/*', async (c) => { const abortController = new AbortController(); let timeoutId: ReturnType | undefined; - // Preserve all request properties (method, headers, body) but attach the - // abort signal so Better Auth's underlying fetch can be cancelled on timeout. const betterAuthRequest = new Request(c.req.raw, { signal: abortController.signal }); try { @@ -403,20 +240,6 @@ app.on(['POST', 'GET'], '/api/auth/*', async (c) => { }); // ── 2. Agent router (authenticated) ────────────────────────────────────────── -// Agent routes are handled by the dedicated agent sub-app, which enforces ZTA -// authentication BEFORE forwarding requests to the Durable Object. This -// replaces the previous pattern where routeAgentRequest() was called before -// auth ran, creating a security gap where any unauthenticated request could -// reach agent endpoints. -// -// The agent router is mounted directly on `app` (not under `/api`) so the -// agents SDK URL pattern `/agents/{slug}/{instanceId}` is preserved exactly. -// -// NOTE: agentRouter handlers return a Response without calling `next()`, so -// the global CORS and secureHeaders middlewares (registered below) never run -// for `/agents/*` requests. We must attach them explicitly here — before the -// sub-app mount — so browser clients (SSE connections in particular) receive -// the correct CORS allowlist enforcement and security headers. app.use( '/agents/*', cors({ @@ -440,7 +263,6 @@ app.use('*', async (c, next) => { const analytics = c.get('analytics'); const requestId = c.get('requestId'); - // PoC routes: skip auth, use anonymous context with rate limiting if (pathname.startsWith('/poc/') || pathname === '/poc') { const rl = await checkRateLimitTiered(c.env, ip, ANONYMOUS_AUTH_CONTEXT); if (!rl.allowed) { @@ -466,11 +288,9 @@ app.use('*', async (c, next) => { return; } - // Pre-auth API meta routes: apply anonymous-tier rate limiting, skip unified auth const isPreAuth = c.req.method === 'GET' && ( PRE_AUTH_PATHS.includes(pathname as typeof PRE_AUTH_PATHS[number]) || pathname.startsWith('/api/deployments') || - // Bare-path monitoring endpoints (routes sub-app mounted at /) MONITORING_BARE_PATHS.has(pathname) ); if (isPreAuth) { @@ -483,7 +303,6 @@ app.use('*', async (c, next) => { rateLimit: rl.limit, windowSeconds: RATE_LIMIT_WINDOW, }); - // ZTA security event — feeds real-time Zero Trust dashboards and SIEM pipelines. analytics.trackSecurityEvent({ eventType: 'rate_limit', path: pathname, @@ -508,7 +327,6 @@ app.use('*', async (c, next) => { return; } - // ── Better Auth session provider ────────────────────────────────────────── startTime(c, 'auth', 'Authentication'); const authProvider = new BetterAuthProvider(c.env); const authResult = await authenticateRequestUnified( @@ -542,11 +360,11 @@ app.use( // ── 5. Secure headers ───────────────────────────────────────────────────────── app.use('*', secureHeaders()); -// ── 6. Pretty JSON (debug mode: add ?pretty=true to any response) ───────────── +// ── 6. Pretty JSON ──────────────────────────────────────────────────────────── app.use('*', prettyJSON()); // ============================================================================ -// PoC routes (static assets or 503) — handled in auth middleware +// PoC routes // ============================================================================ app.all('/poc', async (c) => { @@ -563,11 +381,6 @@ app.all('/poc/*', async (c) => { // Pre-auth API meta routes // ============================================================================ -/** - * Shared handler for all pre-auth API meta paths. - * Lazily imports `routeApiMeta` so the deployment/version code is NOT - * bundled into the isolate for unrelated requests. - */ async function handleApiMeta(c: AppContext): Promise { const { routeApiMeta } = await import('./handlers/info.ts'); const url = new URL(c.req.url); @@ -575,63 +388,13 @@ async function handleApiMeta(c: AppContext): Promise { return res ?? c.json({ success: false, error: 'Not found' }, 404); } -/** - * Admin session revocation handler — revoke all sessions for a specific user. - * - * ZTA compliance: - * - Requires admin role - * - Verifies Cloudflare Access JWT (defense-in-depth) - * - Emits `cf_access_denial` security event on CF Access failure - */ -export async function handleAdminRevokeUserSessions(c: AppContext): Promise { - const authContext = c.get('authContext'); - if (authContext.role !== 'admin') { - return c.json({ success: false, error: 'Admin access required' }, 403); - } - - // Defense-in-depth: verify CF Access JWT when configured - const cfAccess = await verifyCfAccessJwt(c.req.raw, c.env); - if (!cfAccess.valid) { - if (c.env.ANALYTICS_ENGINE) { - new AnalyticsService(c.env.ANALYTICS_ENGINE).trackSecurityEvent({ - eventType: 'cf_access_denial', - path: c.req.path, - method: c.req.method, - reason: cfAccess.error ?? 'CF Access verification failed', - }); - } - return c.json({ success: false, error: cfAccess.error ?? 'CF Access verification failed' }, 403); - } - - const userId = c.req.param('id')!; - try { - if (!c.env.HYPERDRIVE) { - return c.json({ success: false, error: 'Database not configured' }, 503); - } - const pool = createPgPool(c.env.HYPERDRIVE.connectionString); - const result = await pool.query( - 'DELETE FROM sessions WHERE user_id = $1', - [userId], - ); - return c.json({ - success: true, - message: `Revoked ${result.rowCount ?? 0} session(s) for user ${userId}`, - }); - } catch (error) { - // deno-lint-ignore no-console - console.error('[admin] Session revocation error:', error instanceof Error ? error.message : 'unknown'); - return c.json({ success: false, error: 'Failed to revoke sessions' }, 500); - } -} - app.get('/api', handleApiMeta); -app.get('/api/version', cache({ cacheName: 'api-version', cacheControl: 'public, max-age=3600' }), handleApiMeta); -app.get('/api/schemas', cache({ cacheName: 'api-schemas', cacheControl: 'public, max-age=3600' }), handleApiMeta); +app.get('/api/version', handleApiMeta); +app.get('/api/schemas', handleApiMeta); app.get('/api/deployments', handleApiMeta); app.get('/api/deployments/*', handleApiMeta); app.get('/api/turnstile-config', handleApiMeta); app.get('/api/sentry-config', handleApiMeta); -// Public: returns which auth providers are active — used by frontend to conditionally render social login buttons. app.get('/api/auth/providers', (c) => handleAuthProviders(c.req.raw, c.env)); // ============================================================================ @@ -640,22 +403,11 @@ app.get('/api/auth/providers', (c) => handleAuthProviders(c.req.raw, c.env)); const routes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); -// ── logger + compress scoped to the business routes sub-app ────────────────── -// Registered on `routes` (not on `app`) so these middleware never wrap the -// Better Auth handler responses. app.on('/api/auth/*') is resolved before the -// routes sub-app mount, so auth traffic is completely unaffected. routes.use('*', logger()); -// Compress all routes EXCEPT health/smoke diagnostics — those must return -// raw JSON so curl | jq works without Accept-Encoding negotiation. -// Cloudflare's edge can strip or re-encode Accept-Encoding before it reaches -// the Worker, which means compress() would encode /health even for plain curl. const NO_COMPRESS_PATHS = new Set(['/health', '/health/db-smoke', '/health/latest', '/metrics']); -// Instantiate once — avoids creating a new closure on every request. const compressMiddleware = compress(); routes.use('*', async (c, next) => { - // routesPath() strips the /api prefix so the path matches the canonical - // route registered in ROUTE_PERMISSION_REGISTRY (e.g. /health not /api/health). const path = routesPath(c); if (NO_COMPRESS_PATHS.has(path)) { await next(); @@ -664,13 +416,10 @@ routes.use('*', async (c, next) => { return compressMiddleware(c, next); }); -// ZTA: per-user API access gate + usage tracking routes.use('*', async (c, next) => { const authContext = c.get('authContext'); const analytics = c.get('analytics'); const ip = c.get('ip'); - // Middleware receives the original path (before prefix-stripping); normalise it - // so permission/usage records use the canonical path (e.g. /health, not /api/health). const path = routesPath(c); const accessDenied = await checkUserApiAccess(authContext, c.env); @@ -688,10 +437,8 @@ routes.use('*', async (c, next) => { await next(); }); -// Route permission check routes.use('*', async (c, next) => { const path = routesPath(c); - // Skip permission check for /auth/* — Better Auth handles its own routing if (path.startsWith('/auth/')) { await next(); return; @@ -714,467 +461,20 @@ routes.use('*', async (c, next) => { await next(); }); -// ── Admin routes ────────────────────────────────────────────────────────────── - -routes.get('/admin/auth/config', (c) => handleAdminAuthConfig(c.req.raw, c.env, c.get('authContext'))); - -routes.get('/admin/users', (c) => handleAdminListUsers(c.req.raw, c.env, c.get('authContext'))); -routes.get('/admin/users/:id', (c) => handleAdminGetUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!)); -routes.patch( - '/admin/users/:id', - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', AdminUpdateUserSchema as any, zodValidationError), - (c) => handleAdminUpdateUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), -); -routes.delete( - '/admin/users/:id', - rateLimitMiddleware(), - (c) => handleAdminDeleteUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), -); -routes.post( - '/admin/users/:id/ban', - // deno-lint-ignore no-explicit-any - zValidator('json', AdminBanUserSchema as any, zodValidationError), - (c) => handleAdminBanUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), -); -routes.post( - '/admin/users/:id/unban', - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', AdminUnbanUserSchema as any, zodValidationError), - (c) => handleAdminUnbanUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), -); - -// Admin session revocation — revoke all sessions for a specific user -// Extracted to a named handler for testability and ZTA compliance. -routes.delete( - '/admin/users/:id/sessions', - rateLimitMiddleware(), - async (c) => handleAdminRevokeUserSessions(c), -); - -routes.get('/admin/usage/:userId', (c) => handleAdminGetUserUsage(c.req.raw, c.env, c.get('authContext'), c.req.param('userId')!)); - -routes.all('/admin/storage/*', async (c) => { - // Permission check already ran in the routes middleware above; this handler - // only runs when access is granted (admin tier + admin role). - const { routeAdminStorage } = await import('./handlers/admin.ts'); - return routeAdminStorage(c.req.path, c.req.raw, c.env, c.get('authContext')); -}); - -// ── Admin Neon reporting ───────────────────────────────────────────────────── - -routes.get('/admin/neon/project', (c) => handleAdminNeonGetProject(c.req.raw, c.env, c.get('authContext'))); -routes.get('/admin/neon/branches', (c) => handleAdminNeonListBranches(c.req.raw, c.env, c.get('authContext'))); -routes.get('/admin/neon/branches/:branchId', (c) => handleAdminNeonGetBranch(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); -routes.post( - '/admin/neon/branches', - // deno-lint-ignore no-explicit-any - zValidator('json', AdminNeonCreateBranchSchema as any, zodValidationError), - (c) => handleAdminNeonCreateBranch(c.req.raw, c.env, c.get('authContext')), -); -routes.delete('/admin/neon/branches/:branchId', (c) => handleAdminNeonDeleteBranch(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); -routes.get('/admin/neon/endpoints', (c) => handleAdminNeonListEndpoints(c.req.raw, c.env, c.get('authContext'))); -routes.get('/admin/neon/databases/:branchId', (c) => handleAdminNeonListDatabases(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); -routes.post( - '/admin/neon/query', - // deno-lint-ignore no-explicit-any - zValidator('json', AdminNeonQuerySchema as any, zodValidationError), - (c) => handleAdminNeonQuery(c.req.raw, c.env, c.get('authContext')), -); - -// ── Admin agent data ────────────────────────────────────────────────────────── - -routes.get('/admin/agents/sessions', (c) => handleAdminListAgentSessions(c.req.raw, c.env, c.get('authContext'))); -routes.get('/admin/agents/sessions/:sessionId', (c) => handleAdminGetAgentSession(c.req.raw, c.env, c.get('authContext'), c.req.param('sessionId')!)); -routes.get('/admin/agents/audit', (c) => handleAdminListAgentAuditLog(c.req.raw, c.env, c.get('authContext'))); -routes.delete( - '/admin/agents/sessions/:sessionId', - rateLimitMiddleware(), - (c) => handleAdminTerminateAgentSession(c.req.raw, c.env, c.get('authContext'), c.req.param('sessionId')!), -); - -// ── Metrics ─────────────────────────────────────────────────────────────────── - -routes.get('/metrics/prometheus', etag(), (c) => handlePrometheusMetrics(c.req.raw, c.env)); -routes.get('/metrics', etag(), (c) => handleMetrics(c.env)); - -// ── Queue (lazy) ────────────────────────────────────────────────────────────── - -routes.all('/queue/*', async (c) => { - const { routeQueue } = await import('./handlers/queue.ts'); - return routeQueue(c.req.path, c.req.raw, c.env, c.get('authContext'), c.get('analytics'), c.get('ip')); -}); - -// ── Compile routes ──────────────────────────────────────────────────────────── -// -// All primary compile/validate routes share the same Phase 2 middleware stack: -// 1. bodySizeMiddleware() — reject oversized payloads (413) via clone -// 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 -// -// These routes use `zValidator` BEFORE Turnstile verification so the body -// stream is consumed exactly once. `turnstileMiddleware()` would clone+parse, -// then zValidator would parse again — doubling the work. Instead, Turnstile -// verification is inlined via `verifyTurnstileInline()` which reads the token -// from the already-validated `c.req.valid('json')`. -// -// See docs/architecture/hono-routing.md — Phase 2 for the full middleware -// extraction rationale and execution-order guarantees. - -routes.post( - '/compile', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', CompileRequestSchema as any, zodValidationError), - async (c) => { - // Turnstile verification — reads token from the already-validated body - // (c.req.raw body stream was consumed by zValidator above). - // deno-lint-ignore no-explicit-any - const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); - 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')); - }, -); - -routes.post( - '/compile/stream', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', CompileRequestSchema as any, zodValidationError), - async (c) => { - // 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); - }, -); - -routes.post( - '/compile/batch', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', BatchRequestSyncSchema as any, zodValidationError), - async (c) => { - // 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); - }, -); - -routes.post( - '/ast/parse', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', AstParseRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleASTParseRequest(c.req.raw, c.env), -); - -routes.post( - '/validate', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', ValidateRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleValidate(buildSyntheticRequest(c, c.req.valid('json')), c.env), -); - -// ── WebSocket ───────────────────────────────────────────────────────────────── - -routes.get('/ws/compile', async (c) => { - if (c.env.TURNSTILE_SECRET_KEY) { - const url = new URL(c.req.url); - const token = url.searchParams.get('turnstileToken') || ''; - const result = await verifyTurnstileToken(c.env, token, c.get('ip')); - if (!result.success) { - return c.json({ success: false, error: result.error || 'Turnstile verification failed' }, 403); - } - } - return handleWebSocketUpgrade(c.req.raw, c.env); -}); - -// ── Validate-rule ───────────────────────────────────────────────────────────── - -routes.post( - '/validate-rule', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', ValidateRuleRequestSchema as any, zodValidationError), - (c) => handleValidateRule(c.req.raw, c.env), -); - -// ── Configuration ───────────────────────────────────────────────────────────── - -routes.get( - '/configuration/defaults', - cache({ cacheName: 'config-defaults', cacheControl: 'public, max-age=300' }), - rateLimitMiddleware(), - async (c) => { - const res = await handleConfigurationDefaults(c.req.raw, c.env); - return new Response(res.body, { - status: res.status, - headers: { - ...Object.fromEntries(res.headers), - 'Cache-Control': 'public, max-age=300, stale-while-revalidate=60', - }, - }); - }, -); - -routes.post( - '/configuration/validate', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', ConfigurationValidateRequestSchema as any, zodValidationError), - async (c) => { - // 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); - }, -); - -routes.post( - '/configuration/resolve', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', ResolveRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleConfigurationResolve(c.req.raw, c.env), -); - -// ── Rules (requireAuth) ─────────────────────────────────────────────────────── - -routes.get( - '/rules', - requireAuthMiddleware(), - (c) => handleRulesList(c.req.raw, c.env), -); - -routes.post( - '/rules', - requireAuthMiddleware(), - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', RuleSetCreateSchema as any, zodValidationError), - (c) => handleRulesCreate(c.req.raw, c.env), -); - -routes.get( - '/rules/:id', - requireAuthMiddleware(), - (c) => handleRulesGet(c.req.param('id')!, c.env), -); - -routes.put( - '/rules/:id', - requireAuthMiddleware(), - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', RuleSetUpdateSchema as any, zodValidationError), - (c) => handleRulesUpdate(c.req.param('id')!, c.req.raw, c.env), -); - -routes.delete( - '/rules/:id', - requireAuthMiddleware(), - rateLimitMiddleware(), - (c) => handleRulesDelete(c.req.param('id')!, c.env), -); - -// ── API Keys (requireAuth + interactive session — Better Auth only) ── -// -// Only interactive user sessions (Better Auth cookie/bearer) may manage -// API keys. API-key-on-API-key and anonymous requests are rejected. - -/** Auth methods that represent an interactive user session (not API key or anonymous). */ -const INTERACTIVE_AUTH_METHODS = new Set(['better-auth']); - -routes.post( - '/keys', - requireAuthMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', CreateApiKeyRequestSchema as any, zodValidationError), - async (c) => { - if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); - if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); - return handleCreateApiKey(c.req.raw, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); - }, -); - -routes.get( - '/keys', - requireAuthMiddleware(), - async (c) => { - if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); - if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); - return handleListApiKeys(c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); - }, -); - -routes.delete( - '/keys/:id', - requireAuthMiddleware(), - rateLimitMiddleware(), - async (c) => { - if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); - if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); - return handleRevokeApiKey(c.req.param('id')!, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); - }, -); - -routes.patch( - '/keys/:id', - requireAuthMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', UpdateApiKeyRequestSchema as any, zodValidationError), - async (c) => { - if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); - if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); - return handleUpdateApiKey(c.req.param('id')!, c.req.raw, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); - }, -); - -// ── Webhooks ────────────────────────────────────────────────────────────────── - -routes.post( - '/notify', - requireAuthMiddleware(), - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', WebhookNotifyRequestSchema as any, zodValidationError), - (c) => handleNotify(c.req.raw, c.env), -); - -// ── Async compile ───────────────────────────────────────────────────────────── - -routes.post( - '/compile/async', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', CompileRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleCompileAsync(c.req.raw, c.env), -); - -routes.post( - '/compile/batch/async', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', BatchRequestAsyncSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleCompileBatchAsync(c.req.raw, c.env), -); - -routes.post( - '/compile/container', - bodySizeMiddleware(), - rateLimitMiddleware(), - // deno-lint-ignore no-explicit-any - zValidator('json', CompileRequestSchema as any, zodValidationError), - turnstileMiddleware(), - async (c) => { - if (!c.env.ADBLOCK_COMPILER) { - return c.json({ success: false, error: 'Container binding (ADBLOCK_COMPILER) is not available in this deployment' }, 503); - } - if (!c.env.CONTAINER_SECRET) { - return c.json({ success: false, error: 'CONTAINER_SECRET is not configured' }, 503); - } - const id = c.env.ADBLOCK_COMPILER.idFromName('default'); - const stub = c.env.ADBLOCK_COMPILER.get(id); - const containerReq = new Request('http://container/compile', { - // Note: the URL hostname/scheme is irrelevant for DO stub.fetch() — the stub - // intercepts the call and routes it to the container's internal server. - // The path '/compile' maps to the POST /compile handler in container-server.ts. - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Container-Secret': c.env.CONTAINER_SECRET, - }, - body: c.req.raw.body, - }); - const containerRes = await stub.fetch(containerReq); - return new Response(containerRes.body, { - status: containerRes.status, - headers: containerRes.headers, - }); - }, -); - -// ── Workflow (lazy) ─────────────────────────────────────────────────────────── - -routes.all('/workflow/*', async (c) => { - const { routeWorkflow } = await import('./handlers/workflow.ts'); - const url = new URL(c.req.url); - return routeWorkflow(c.req.path, c.req.raw, c.env, c.get('authContext'), c.get('analytics'), c.get('ip'), url); -}); - -// ── Health (lazy) ───────────────────────────────────────────────────────────── - -routes.get('/health', async (c) => { - const { handleHealth } = await import('./handlers/health.ts'); - const res = await handleHealth(c.env); - // Cache health checks for 30 seconds — stale-while-revalidate for availability - return new Response(res.body, { - status: res.status, - headers: { - ...Object.fromEntries(res.headers), - 'Cache-Control': 'public, max-age=30, stale-while-revalidate=10', - }, - }); -}); - -routes.get('/health/latest', async (c) => { - const { handleHealthLatest } = await import('./handlers/health.ts'); - return handleHealthLatest(c.env); -}); - -routes.get('/health/db-smoke', async (c) => { - const { handleDbSmoke } = await import('./handlers/health.ts'); - return handleDbSmoke(c.env); -}); - -routes.get('/container/status', etag(), async (c) => { - const { handleContainerStatus } = await import('./handlers/container-status.ts'); - const res = await handleContainerStatus(c.env); - // Cache container status briefly to reduce DO load from frequent polling - return new Response(res.body, { - status: res.status, - headers: { - ...Object.fromEntries(res.headers), - 'Cache-Control': 'public, max-age=15, stale-while-revalidate=5', - }, - }); -}); +// ── Mount domain route modules ──────────────────────────────────────────────── +routes.route('/', compileRoutes); +routes.route('/', rulesRoutes); +routes.route('/', queueRoutes); +routes.route('/', configurationRoutes); +routes.route('/', adminRoutes); +routes.route('/', monitoringRoutes); +routes.route('/', apiKeysRoutes); +routes.route('/', webhookRoutes); +routes.route('/', workflowRoutes); +routes.route('/', browserRoutes); // ── Docs redirect ───────────────────────────────────────────────────────────── -/** - * Build the external docs redirect target from a `/docs[/*]` request path. - * Shared by GET and HEAD handlers to keep the redirect logic in one place. - */ function buildDocsRedirectUrl(c: AppContext): string { const pathname = c.req.path; const docsSubpath = pathname.startsWith('/docs/') ? pathname.slice('/docs'.length) : '/'; @@ -1194,31 +494,12 @@ routes.get('*', async (c) => { return serveStaticAsset(c.req.raw, c.env, c.req.path); }); -// ── Mount routes under both / and /api/ ────────────────────────────────────── -// This means /compile and /api/compile both work (frontend uses API_BASE_URL = '/api'). -// -// Design note: double-mounting the same sub-app causes its middleware to run twice -// for /api/* requests — once via the /api mount (path stripped) and once via the / -// mount (full path). Upstream ZTA middleware MUST ensure any permission checks or -// usage tracking are effectively applied once per request, and both middleware layers -// normalise the path by stripping any /api prefix so the permission registry is -// consulted with the canonical path (e.g. /health, not /api/health). -// -// /api is registered first so that /api/* requests get correct Hono prefix-stripping -// before the root-mount sub-app can intercept them as unrecognised paths. +// ── Mount routes ────────────────────────────────────────────────────────────── // ============================================================================ -// OpenAPI Spec endpoint — served at /api/openapi.json without authentication -// so it is publicly discoverable. +// OpenAPI Spec endpoint // ============================================================================ -/** - * Canonical OpenAPI document metadata shared between the live `/api/openapi.json` - * endpoint and the `deno task generate:schema` script. - * - * Import this in `scripts/generate-openapi-schema.ts` instead of duplicating - * the fields so the server URL, version, and info block never drift. - */ export const OPENAPI_DOCUMENT_ARGS = { openapi: '3.0.0' as const, info: { @@ -1247,20 +528,15 @@ app.get('/api/openapi.json', (c) => { }); app.route('/api', routes); -app.route('/', routes); + +// NOTE: app.route('/', routes) was intentionally removed in Phase 4 (domain route split). +// /api is the canonical base path. Legacy bare-path requests (/compile, /health, etc.) +// are no longer served. Update any client using bare paths to use /api/* instead. // ============================================================================ // Exports // ============================================================================ -/** - * Handle a single fetch request using the Hono app. - * Exported for backward compatibility with `worker/handlers/router.ts`. - * - * `_url` and `_pathname` are accepted to match the original signature used in - * the if/else router — callers do not need updating. The Hono app re-derives - * these from the request URL internally. - */ export async function handleRequest( request: Request, env: Env, @@ -1271,9 +547,4 @@ export async function handleRequest( return app.fetch(request, env, ctx); } -/** - * Typed RPC client type for use with `hono/client`'s `hc()`. - * - * @see docs/architecture/hono-rpc-client.md - */ export type AppType = typeof app; diff --git a/worker/routes/admin.routes.ts b/worker/routes/admin.routes.ts new file mode 100644 index 000000000..af4c9cd8c --- /dev/null +++ b/worker/routes/admin.routes.ts @@ -0,0 +1,192 @@ +/// + +/** + * Admin routes. + * + * Routes: + * GET /admin/auth/config + * GET /admin/users + * GET /admin/users/:id + * PATCH /admin/users/:id + * DELETE /admin/users/:id + * POST /admin/users/:id/ban + * POST /admin/users/:id/unban + * DELETE /admin/users/:id/sessions + * GET /admin/usage/:userId + * ALL /admin/storage/* + * GET /admin/neon/project + * GET /admin/neon/branches + * GET /admin/neon/branches/:branchId + * POST /admin/neon/branches + * DELETE /admin/neon/branches/:branchId + * GET /admin/neon/endpoints + * GET /admin/neon/databases/:branchId + * POST /admin/neon/query + * GET /admin/agents/sessions + * GET /admin/agents/sessions/:sessionId + * GET /admin/agents/audit + * DELETE /admin/agents/sessions/:sessionId + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { type AppContext, zodValidationError } from './shared.ts'; + +import { rateLimitMiddleware } from '../middleware/hono-middleware.ts'; +import { verifyCfAccessJwt } from '../middleware/cf-access.ts'; +import { AnalyticsService } from '../../src/services/AnalyticsService.ts'; +import { createPgPool } from '../utils/pg-pool.ts'; + +import { handleAdminBanUser, handleAdminDeleteUser, handleAdminGetUser, handleAdminListUsers, handleAdminUnbanUser, handleAdminUpdateUser } from '../handlers/admin-users.ts'; +import { handleAdminAuthConfig } from '../handlers/auth-config.ts'; +import { handleAdminGetUserUsage } from '../handlers/admin-usage.ts'; +import { + handleAdminNeonCreateBranch, + handleAdminNeonDeleteBranch, + handleAdminNeonGetBranch, + handleAdminNeonGetProject, + handleAdminNeonListBranches, + handleAdminNeonListDatabases, + handleAdminNeonListEndpoints, + handleAdminNeonQuery, +} from '../handlers/admin-neon.ts'; +import { handleAdminGetAgentSession, handleAdminListAgentAuditLog, handleAdminListAgentSessions, handleAdminTerminateAgentSession } from '../handlers/admin-agents.ts'; + +import { AdminBanUserSchema, AdminNeonCreateBranchSchema, AdminNeonQuerySchema, AdminUnbanUserSchema, AdminUpdateUserSchema } from '../schemas.ts'; + +export const adminRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Admin session revocation handler ───────────────────────────────────────── + +/** + * Admin session revocation handler — revoke all sessions for a specific user. + * + * ZTA compliance: + * - Requires admin role + * - Verifies Cloudflare Access JWT (defense-in-depth) + * - Emits `cf_access_denial` security event on CF Access failure + */ +export async function handleAdminRevokeUserSessions(c: AppContext): Promise { + const authContext = c.get('authContext'); + if (authContext.role !== 'admin') { + return c.json({ success: false, error: 'Admin access required' }, 403); + } + + // Defense-in-depth: verify CF Access JWT when configured + const cfAccess = await verifyCfAccessJwt(c.req.raw, c.env); + if (!cfAccess.valid) { + if (c.env.ANALYTICS_ENGINE) { + new AnalyticsService(c.env.ANALYTICS_ENGINE).trackSecurityEvent({ + eventType: 'cf_access_denial', + path: c.req.path, + method: c.req.method, + reason: cfAccess.error ?? 'CF Access verification failed', + }); + } + return c.json({ success: false, error: cfAccess.error ?? 'CF Access verification failed' }, 403); + } + + const userId = c.req.param('id')!; + try { + if (!c.env.HYPERDRIVE) { + return c.json({ success: false, error: 'Database not configured' }, 503); + } + const pool = createPgPool(c.env.HYPERDRIVE.connectionString); + const result = await pool.query( + 'DELETE FROM sessions WHERE user_id = $1', + [userId], + ); + return c.json({ + success: true, + message: `Revoked ${result.rowCount ?? 0} session(s) for user ${userId}`, + }); + } catch (error) { + // deno-lint-ignore no-console + console.error('[admin] Session revocation error:', error instanceof Error ? error.message : 'unknown'); + return c.json({ success: false, error: 'Failed to revoke sessions' }, 500); + } +} + +// ── Admin routes ────────────────────────────────────────────────────────────── + +adminRoutes.get('/admin/auth/config', (c) => handleAdminAuthConfig(c.req.raw, c.env, c.get('authContext'))); + +adminRoutes.get('/admin/users', (c) => handleAdminListUsers(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.get('/admin/users/:id', (c) => handleAdminGetUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!)); +adminRoutes.patch( + '/admin/users/:id', + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', AdminUpdateUserSchema as any, zodValidationError), + (c) => handleAdminUpdateUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), +); +adminRoutes.delete( + '/admin/users/:id', + rateLimitMiddleware(), + (c) => handleAdminDeleteUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), +); +adminRoutes.post( + '/admin/users/:id/ban', + // deno-lint-ignore no-explicit-any + zValidator('json', AdminBanUserSchema as any, zodValidationError), + (c) => handleAdminBanUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), +); +adminRoutes.post( + '/admin/users/:id/unban', + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', AdminUnbanUserSchema as any, zodValidationError), + (c) => handleAdminUnbanUser(c.req.raw, c.env, c.get('authContext'), c.req.param('id')!), +); + +// Admin session revocation — revoke all sessions for a specific user +// Extracted to a named handler for testability and ZTA compliance. +adminRoutes.delete( + '/admin/users/:id/sessions', + rateLimitMiddleware(), + async (c) => handleAdminRevokeUserSessions(c), +); + +adminRoutes.get('/admin/usage/:userId', (c) => handleAdminGetUserUsage(c.req.raw, c.env, c.get('authContext'), c.req.param('userId')!)); + +adminRoutes.all('/admin/storage/*', async (c) => { + // Permission check already ran in the routes middleware above; this handler + // only runs when access is granted (admin tier + admin role). + const { routeAdminStorage } = await import('../handlers/admin.ts'); + return routeAdminStorage(c.req.path, c.req.raw, c.env, c.get('authContext')); +}); + +// ── Admin Neon reporting ───────────────────────────────────────────────────── + +adminRoutes.get('/admin/neon/project', (c) => handleAdminNeonGetProject(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.get('/admin/neon/branches', (c) => handleAdminNeonListBranches(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.get('/admin/neon/branches/:branchId', (c) => handleAdminNeonGetBranch(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); +adminRoutes.post( + '/admin/neon/branches', + // deno-lint-ignore no-explicit-any + zValidator('json', AdminNeonCreateBranchSchema as any, zodValidationError), + (c) => handleAdminNeonCreateBranch(c.req.raw, c.env, c.get('authContext')), +); +adminRoutes.delete('/admin/neon/branches/:branchId', (c) => handleAdminNeonDeleteBranch(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); +adminRoutes.get('/admin/neon/endpoints', (c) => handleAdminNeonListEndpoints(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.get('/admin/neon/databases/:branchId', (c) => handleAdminNeonListDatabases(c.req.raw, c.env, c.get('authContext'), c.req.param('branchId')!)); +adminRoutes.post( + '/admin/neon/query', + // deno-lint-ignore no-explicit-any + zValidator('json', AdminNeonQuerySchema as any, zodValidationError), + (c) => handleAdminNeonQuery(c.req.raw, c.env, c.get('authContext')), +); + +// ── Admin agent data ────────────────────────────────────────────────────────── + +adminRoutes.get('/admin/agents/sessions', (c) => handleAdminListAgentSessions(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.get('/admin/agents/sessions/:sessionId', (c) => handleAdminGetAgentSession(c.req.raw, c.env, c.get('authContext'), c.req.param('sessionId')!)); +adminRoutes.get('/admin/agents/audit', (c) => handleAdminListAgentAuditLog(c.req.raw, c.env, c.get('authContext'))); +adminRoutes.delete( + '/admin/agents/sessions/:sessionId', + rateLimitMiddleware(), + (c) => handleAdminTerminateAgentSession(c.req.raw, c.env, c.get('authContext'), c.req.param('sessionId')!), +); diff --git a/worker/routes/api-keys.routes.ts b/worker/routes/api-keys.routes.ts new file mode 100644 index 000000000..aef5f2c83 --- /dev/null +++ b/worker/routes/api-keys.routes.ts @@ -0,0 +1,85 @@ +/// + +/** + * API key management routes. + * + * Routes: + * POST /keys + * GET /keys + * DELETE /keys/:id + * PATCH /keys/:id + * + * NOTE: Only interactive user sessions (Better Auth cookie/bearer) may manage + * API keys. API-key-on-API-key and anonymous requests are rejected. + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { zodValidationError } from './shared.ts'; + +import { rateLimitMiddleware, requireAuthMiddleware } from '../middleware/hono-middleware.ts'; + +import { handleCreateApiKey, handleListApiKeys, handleRevokeApiKey, handleUpdateApiKey } from '../handlers/api-keys.ts'; +import { CreateApiKeyRequestSchema, UpdateApiKeyRequestSchema } from '../schemas.ts'; +import { JsonResponse } from '../utils/response.ts'; +import { createPgPool } from '../utils/pg-pool.ts'; + +/** Auth methods that represent an interactive user session (not API key or anonymous). */ +const INTERACTIVE_AUTH_METHODS = new Set(['better-auth']); + +export const apiKeysRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── API Keys (requireAuth + interactive session — Better Auth only) ── +// +// Only interactive user sessions (Better Auth cookie/bearer) may manage +// API keys. API-key-on-API-key and anonymous requests are rejected. + +apiKeysRoutes.post( + '/keys', + requireAuthMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', CreateApiKeyRequestSchema as any, zodValidationError), + async (c) => { + if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); + if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); + return handleCreateApiKey(c.req.raw, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); + }, +); + +apiKeysRoutes.get( + '/keys', + requireAuthMiddleware(), + async (c) => { + if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); + if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); + return handleListApiKeys(c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); + }, +); + +apiKeysRoutes.delete( + '/keys/:id', + requireAuthMiddleware(), + rateLimitMiddleware(), + async (c) => { + if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); + if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); + return handleRevokeApiKey(c.req.param('id')!, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); + }, +); + +apiKeysRoutes.patch( + '/keys/:id', + requireAuthMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', UpdateApiKeyRequestSchema as any, zodValidationError), + async (c) => { + if (!INTERACTIVE_AUTH_METHODS.has(c.get('authContext').authMethod)) return JsonResponse.forbidden('API key management requires an authenticated user session'); + if (!c.env.HYPERDRIVE) return JsonResponse.serviceUnavailable('Database not configured'); + return handleUpdateApiKey(c.req.param('id')!, c.req.raw, c.get('authContext'), c.env.HYPERDRIVE.connectionString, createPgPool); + }, +); diff --git a/worker/routes/browser.routes.ts b/worker/routes/browser.routes.ts new file mode 100644 index 000000000..e78fc66ad --- /dev/null +++ b/worker/routes/browser.routes.ts @@ -0,0 +1,15 @@ +/// + +/** + * Browser routes — stub only. + * + * Browser routes will be added in a future PR. + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; + +// Stub — browser routes will be added in a future PR. +export const browserRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); diff --git a/worker/routes/compile.routes.ts b/worker/routes/compile.routes.ts new file mode 100644 index 000000000..2e4e40400 --- /dev/null +++ b/worker/routes/compile.routes.ts @@ -0,0 +1,210 @@ +/// + +/** + * Compile, validate, AST parse and WebSocket routes. + * + * Routes: + * POST /compile + * POST /compile/stream + * POST /compile/batch + * POST /ast/parse + * POST /validate + * GET /ws/compile + * POST /validate-rule + * POST /compile/async + * POST /compile/batch/async + * POST /compile/container + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { buildSyntheticRequest, verifyTurnstileInline, zodValidationError } from './shared.ts'; + +import { bodySizeMiddleware, rateLimitMiddleware, turnstileMiddleware } from '../middleware/hono-middleware.ts'; +import { verifyTurnstileToken } from '../middleware/index.ts'; + +import { + handleASTParseRequest, + handleCompileAsync, + handleCompileBatch, + handleCompileBatchAsync, + handleCompileJson, + handleCompileStream, + handleValidate, +} from '../handlers/compile.ts'; +import { handleValidateRule } from '../handlers/validate-rule.ts'; +import { handleWebSocketUpgrade } from '../websocket.ts'; + +import { BatchRequestAsyncSchema, BatchRequestSyncSchema, CompileRequestSchema } from '../../src/configuration/schemas.ts'; +import { AstParseRequestSchema, ValidateRequestSchema, ValidateRuleRequestSchema } from '../schemas.ts'; + +export const compileRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Compile routes ──────────────────────────────────────────────────────────── +// +// All primary compile/validate routes share the same Phase 2 middleware stack: +// 1. bodySizeMiddleware() — reject oversized payloads (413) via clone +// 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 +// +// These routes use `zValidator` BEFORE Turnstile verification so the body +// stream is consumed exactly once. `turnstileMiddleware()` would clone+parse, +// then zValidator would parse again — doubling the work. Instead, Turnstile +// verification is inlined via `verifyTurnstileInline()` which reads the token +// from the already-validated `c.req.valid('json')`. +// +// See docs/architecture/hono-routing.md — Phase 2 for the full middleware +// extraction rationale and execution-order guarantees. + +compileRoutes.post( + '/compile', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', CompileRequestSchema as any, zodValidationError), + async (c) => { + // Turnstile verification — reads token from the already-validated body + // (c.req.raw body stream was consumed by zValidator above). + // deno-lint-ignore no-explicit-any + const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); + 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')); + }, +); + +compileRoutes.post( + '/compile/stream', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', CompileRequestSchema as any, zodValidationError), + async (c) => { + // 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); + }, +); + +compileRoutes.post( + '/compile/batch', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', BatchRequestSyncSchema as any, zodValidationError), + async (c) => { + // 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); + }, +); + +compileRoutes.post( + '/ast/parse', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', AstParseRequestSchema as any, zodValidationError), + turnstileMiddleware(), + (c) => handleASTParseRequest(c.req.raw, c.env), +); + +compileRoutes.post( + '/validate', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', ValidateRequestSchema as any, zodValidationError), + turnstileMiddleware(), + (c) => handleValidate(buildSyntheticRequest(c, c.req.valid('json')), c.env), +); + +// ── WebSocket ───────────────────────────────────────────────────────────────── + +compileRoutes.get('/ws/compile', async (c) => { + if (c.env.TURNSTILE_SECRET_KEY) { + const url = new URL(c.req.url); + const token = url.searchParams.get('turnstileToken') || ''; + const result = await verifyTurnstileToken(c.env, token, c.get('ip')); + if (!result.success) { + return c.json({ success: false, error: result.error || 'Turnstile verification failed' }, 403); + } + } + return handleWebSocketUpgrade(c.req.raw, c.env); +}); + +// ── Validate-rule ───────────────────────────────────────────────────────────── + +compileRoutes.post( + '/validate-rule', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', ValidateRuleRequestSchema as any, zodValidationError), + (c) => handleValidateRule(c.req.raw, c.env), +); + +// ── Async compile ───────────────────────────────────────────────────────────── + +compileRoutes.post( + '/compile/async', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', CompileRequestSchema as any, zodValidationError), + turnstileMiddleware(), + (c) => handleCompileAsync(c.req.raw, c.env), +); + +compileRoutes.post( + '/compile/batch/async', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', BatchRequestAsyncSchema as any, zodValidationError), + turnstileMiddleware(), + (c) => handleCompileBatchAsync(c.req.raw, c.env), +); + +compileRoutes.post( + '/compile/container', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', CompileRequestSchema as any, zodValidationError), + turnstileMiddleware(), + async (c) => { + if (!c.env.ADBLOCK_COMPILER) { + return c.json({ success: false, error: 'Container binding (ADBLOCK_COMPILER) is not available in this deployment' }, 503); + } + if (!c.env.CONTAINER_SECRET) { + return c.json({ success: false, error: 'CONTAINER_SECRET is not configured' }, 503); + } + const id = c.env.ADBLOCK_COMPILER.idFromName('default'); + const stub = c.env.ADBLOCK_COMPILER.get(id); + const containerReq = new Request('http://container/compile', { + // Note: the URL hostname/scheme is irrelevant for DO stub.fetch() — the stub + // intercepts the call and routes it to the container's internal server. + // The path '/compile' maps to the POST /compile handler in container-server.ts. + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Container-Secret': c.env.CONTAINER_SECRET, + }, + body: c.req.raw.body, + }); + const containerRes = await stub.fetch(containerReq); + return new Response(containerRes.body, { + status: containerRes.status, + headers: containerRes.headers, + }); + }, +); diff --git a/worker/routes/configuration.routes.ts b/worker/routes/configuration.routes.ts new file mode 100644 index 000000000..75d9488d2 --- /dev/null +++ b/worker/routes/configuration.routes.ts @@ -0,0 +1,67 @@ +/// + +/** + * Configuration routes. + * + * Routes: + * GET /configuration/defaults + * POST /configuration/validate + * POST /configuration/resolve + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; +import { cache } from 'hono/cache'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { buildSyntheticRequest, verifyTurnstileInline, zodValidationError } from './shared.ts'; + +import { bodySizeMiddleware, rateLimitMiddleware, turnstileMiddleware } from '../middleware/hono-middleware.ts'; + +import { handleConfigurationDefaults, handleConfigurationResolve, handleConfigurationValidate } from '../handlers/configuration.ts'; +import { ConfigurationValidateRequestSchema, ResolveRequestSchema } from '../handlers/configuration.ts'; + +export const configurationRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Configuration ───────────────────────────────────────────────────────────── + +configurationRoutes.get( + '/configuration/defaults', + cache({ cacheName: 'config-defaults', cacheControl: 'public, max-age=300' }), + rateLimitMiddleware(), + async (c) => { + const res = await handleConfigurationDefaults(c.req.raw, c.env); + return new Response(res.body, { + status: res.status, + headers: { + ...Object.fromEntries(res.headers), + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=60', + }, + }); + }, +); + +configurationRoutes.post( + '/configuration/validate', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', ConfigurationValidateRequestSchema as any, zodValidationError), + async (c) => { + // 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); + }, +); + +configurationRoutes.post( + '/configuration/resolve', + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', ResolveRequestSchema as any, zodValidationError), + turnstileMiddleware(), + (c) => handleConfigurationResolve(c.req.raw, c.env), +); diff --git a/worker/routes/index.ts b/worker/routes/index.ts new file mode 100644 index 000000000..06f6ac15f --- /dev/null +++ b/worker/routes/index.ts @@ -0,0 +1,18 @@ +/** + * Barrel export for all route modules. + * + * @see worker/hono-app.ts — route mounting + */ + +export { compileRoutes } from './compile.routes.ts'; +export { rulesRoutes } from './rules.routes.ts'; +export { queueRoutes } from './queue.routes.ts'; +export { configurationRoutes } from './configuration.routes.ts'; +export { adminRoutes, handleAdminRevokeUserSessions } from './admin.routes.ts'; +export { monitoringRoutes } from './monitoring.routes.ts'; +export { apiKeysRoutes } from './api-keys.routes.ts'; +export { webhookRoutes } from './webhook.routes.ts'; +export { workflowRoutes } from './workflow.routes.ts'; +export { browserRoutes } from './browser.routes.ts'; +export type { AppContext, Variables } from './shared.ts'; +export { buildSyntheticRequest, verifyTurnstileInline, zodValidationError } from './shared.ts'; diff --git a/worker/routes/monitoring.routes.ts b/worker/routes/monitoring.routes.ts new file mode 100644 index 000000000..8a819a14d --- /dev/null +++ b/worker/routes/monitoring.routes.ts @@ -0,0 +1,67 @@ +/// + +/** + * Monitoring and health routes. + * + * Routes: + * GET /metrics/prometheus + * GET /metrics + * GET /health + * GET /health/latest + * GET /health/db-smoke + * GET /container/status + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { etag } from 'hono/etag'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; + +import { handlePrometheusMetrics } from '../handlers/prometheus-metrics.ts'; +import { handleMetrics } from '../handlers/metrics.ts'; + +export const monitoringRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Metrics ─────────────────────────────────────────────────────────────────── + +monitoringRoutes.get('/metrics/prometheus', etag(), (c) => handlePrometheusMetrics(c.req.raw, c.env)); +monitoringRoutes.get('/metrics', etag(), (c) => handleMetrics(c.env)); + +// ── Health (lazy) ───────────────────────────────────────────────────────────── + +monitoringRoutes.get('/health', async (c) => { + const { handleHealth } = await import('../handlers/health.ts'); + const res = await handleHealth(c.env); + // Cache health checks for 30 seconds — stale-while-revalidate for availability + return new Response(res.body, { + status: res.status, + headers: { + ...Object.fromEntries(res.headers), + 'Cache-Control': 'public, max-age=30, stale-while-revalidate=10', + }, + }); +}); + +monitoringRoutes.get('/health/latest', async (c) => { + const { handleHealthLatest } = await import('../handlers/health.ts'); + return handleHealthLatest(c.env); +}); + +monitoringRoutes.get('/health/db-smoke', async (c) => { + const { handleDbSmoke } = await import('../handlers/health.ts'); + return handleDbSmoke(c.env); +}); + +monitoringRoutes.get('/container/status', etag(), async (c) => { + const { handleContainerStatus } = await import('../handlers/container-status.ts'); + const res = await handleContainerStatus(c.env); + // Cache container status briefly to reduce DO load from frequent polling + return new Response(res.body, { + status: res.status, + headers: { + ...Object.fromEntries(res.headers), + 'Cache-Control': 'public, max-age=15, stale-while-revalidate=5', + }, + }); +}); diff --git a/worker/routes/queue.routes.ts b/worker/routes/queue.routes.ts new file mode 100644 index 000000000..bee6e0873 --- /dev/null +++ b/worker/routes/queue.routes.ts @@ -0,0 +1,22 @@ +/// + +/** + * Queue routes (lazy-loaded handler). + * + * Routes: + * ALL /queue/* + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; + +export const queueRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Queue (lazy) ────────────────────────────────────────────────────────────── + +queueRoutes.all('/queue/*', async (c) => { + const { routeQueue } = await import('../handlers/queue.ts'); + return routeQueue(c.req.path, c.req.raw, c.env, c.get('authContext'), c.get('analytics'), c.get('ip')); +}); diff --git a/worker/routes/rules.routes.ts b/worker/routes/rules.routes.ts new file mode 100644 index 000000000..ab21dc5e7 --- /dev/null +++ b/worker/routes/rules.routes.ts @@ -0,0 +1,67 @@ +/// + +/** + * Rules (saved rule-sets) routes. + * + * Routes: + * GET /rules + * POST /rules + * GET /rules/:id + * PUT /rules/:id + * DELETE /rules/:id + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { zodValidationError } from './shared.ts'; + +import { bodySizeMiddleware, rateLimitMiddleware, requireAuthMiddleware } from '../middleware/hono-middleware.ts'; + +import { handleRulesCreate, handleRulesDelete, handleRulesGet, handleRulesList, handleRulesUpdate } from '../handlers/rules.ts'; +import { RuleSetCreateSchema, RuleSetUpdateSchema } from '../schemas.ts'; + +export const rulesRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Rules (requireAuth) ─────────────────────────────────────────────────────── + +rulesRoutes.get( + '/rules', + requireAuthMiddleware(), + (c) => handleRulesList(c.req.raw, c.env), +); + +rulesRoutes.post( + '/rules', + requireAuthMiddleware(), + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', RuleSetCreateSchema as any, zodValidationError), + (c) => handleRulesCreate(c.req.raw, c.env), +); + +rulesRoutes.get( + '/rules/:id', + requireAuthMiddleware(), + (c) => handleRulesGet(c.req.param('id')!, c.env), +); + +rulesRoutes.put( + '/rules/:id', + requireAuthMiddleware(), + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', RuleSetUpdateSchema as any, zodValidationError), + (c) => handleRulesUpdate(c.req.param('id')!, c.req.raw, c.env), +); + +rulesRoutes.delete( + '/rules/:id', + requireAuthMiddleware(), + rateLimitMiddleware(), + (c) => handleRulesDelete(c.req.param('id')!, c.env), +); diff --git a/worker/routes/shared.ts b/worker/routes/shared.ts new file mode 100644 index 000000000..ca6b40c99 --- /dev/null +++ b/worker/routes/shared.ts @@ -0,0 +1,103 @@ +/// + +/** + * Shared types and helper functions used by route modules and hono-app.ts. + * + * Extracted here to avoid circular imports: hono-app.ts imports route modules, + * and route modules must NOT import from hono-app.ts at runtime. + * + * @see worker/hono-app.ts — main app setup, middleware, and route mounting + */ + +import type { Context } from 'hono'; +import type { Env, IAuthContext } from '../types.ts'; +import { AnalyticsService } from '../../src/services/AnalyticsService.ts'; +import { verifyTurnstileToken } from '../middleware/index.ts'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Hono context variables set by middleware and available in route handlers. + */ +export interface Variables { + authContext: IAuthContext; + analytics: AnalyticsService; + requestId: string; + ip: string; + isSSR: boolean; // true when the request originated from the SSR Worker via env.API.fetch() +} + +export type AppContext = Context<{ Bindings: Env; Variables: Variables }>; + +// ============================================================================ +// Helper functions +// ============================================================================ + +/** + * Shared zValidator error callback — returns a 422 JSON response when Zod + * validation fails. Used across all `zValidator('json', ...)` calls. + * + * @hono/zod-validator types against npm:zod while this project uses + * jsr:@zod/zod — both are Zod v4 with identical runtime APIs; the cast + * to `any` avoids a module-identity mismatch that is type-only. + * + * When validation fails, `result.error` is a `ZodError` instance from + * jsr:@zod/zod — typed as `unknown` here to bridge the module identity gap, + * but serialised as-is into the 422 response body so callers receive full + * structured error details. + * + * @example + * ```ts + * zValidator('json', SomeSchema as any, zodValidationError) + * ``` + */ +// deno-lint-ignore no-explicit-any +export function zodValidationError(result: { success: boolean; error?: unknown }, c: AppContext): Response | void { + if (!result.success) { + return c.json({ success: false, error: 'Invalid request body', details: result.error }, 422); + } +} + +/** + * Verify a Turnstile token extracted from an already-validated JSON body. + * + * Must be called AFTER `zValidator` has consumed the body stream (when the + * `turnstileToken` field is accessed via `c.req.valid('json')`). + * + * Returns the error `Response` (403) on rejection, or `null` when the + * Turnstile check passes (or when Turnstile is not configured). + */ +export async function verifyTurnstileInline(c: AppContext, token: string): Promise { + if (!c.env.TURNSTILE_SECRET_KEY) return null; + const tsResult = await verifyTurnstileToken(c.env, token, c.get('ip')); + if (!tsResult.success) { + c.get('analytics').trackSecurityEvent({ + eventType: 'turnstile_rejection', + path: c.req.path, + method: c.req.method, + clientIpHash: AnalyticsService.hashIp(c.get('ip')), + tier: c.get('authContext').tier, + reason: tsResult.error ?? 'turnstile_verification_failed', + }); + return c.json({ success: false, error: tsResult.error ?? 'Turnstile verification failed' }, 403); + } + return null; +} + +/** + * Reconstruct a synthetic `Request` from a validated body. + * + * When `zValidator` consumes the original body stream, the existing handler + * functions (which accept a `Request`) cannot re-read `c.req.raw`. This + * helper creates a new `Request` that re-serialises the validated body so the + * handlers can continue using their existing `request.json()` API. + */ +export function buildSyntheticRequest(c: AppContext, validatedBody: unknown): Request { + return new Request(c.req.url, { + method: 'POST', + headers: c.req.raw.headers, + body: JSON.stringify(validatedBody), + }); +} diff --git a/worker/routes/webhook.routes.ts b/worker/routes/webhook.routes.ts new file mode 100644 index 000000000..333bd0cf3 --- /dev/null +++ b/worker/routes/webhook.routes.ts @@ -0,0 +1,34 @@ +/// + +/** + * Webhook notification routes. + * + * Routes: + * POST /notify + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; +import { zValidator } from '@hono/zod-validator'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; +import { zodValidationError } from './shared.ts'; + +import { bodySizeMiddleware, rateLimitMiddleware, requireAuthMiddleware } from '../middleware/hono-middleware.ts'; + +import { handleNotify } from '../handlers/webhook.ts'; +import { WebhookNotifyRequestSchema } from '../schemas.ts'; + +export const webhookRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Webhooks ────────────────────────────────────────────────────────────────── + +webhookRoutes.post( + '/notify', + requireAuthMiddleware(), + bodySizeMiddleware(), + rateLimitMiddleware(), + // deno-lint-ignore no-explicit-any + zValidator('json', WebhookNotifyRequestSchema as any, zodValidationError), + (c) => handleNotify(c.req.raw, c.env), +); diff --git a/worker/routes/workflow.routes.ts b/worker/routes/workflow.routes.ts new file mode 100644 index 000000000..423bc7362 --- /dev/null +++ b/worker/routes/workflow.routes.ts @@ -0,0 +1,23 @@ +/// + +/** + * Workflow routes (lazy-loaded handler). + * + * Routes: + * ALL /workflow/* + */ + +import { OpenAPIHono } from '@hono/zod-openapi'; + +import type { Env } from '../types.ts'; +import type { Variables } from './shared.ts'; + +export const workflowRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); + +// ── Workflow (lazy) ─────────────────────────────────────────────────────────── + +workflowRoutes.all('/workflow/*', async (c) => { + const { routeWorkflow } = await import('../handlers/workflow.ts'); + const url = new URL(c.req.url); + return routeWorkflow(c.req.path, c.req.raw, c.env, c.get('authContext'), c.get('analytics'), c.get('ip'), url); +}); From 95eb0eb54267e801d6267ec7482a0e622f26445c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 04:59:05 +0000 Subject: [PATCH 3/3] fix(worker): correct Turnstile/zValidator ordering in route modules; restore cache middleware - compile.routes.ts: - /ast/parse, /validate: move turnstileMiddleware() BEFORE zValidator() so it reads from the unconsumed body clone; update handlers to use buildSyntheticRequest(c, c.req.valid('json')) since zValidator now consumes the body after Turnstile runs - /compile/async, /compile/batch/async: replace turnstileMiddleware() with verifyTurnstileInline() pattern (reads turnstileToken from c.req.valid('json')) and use buildSyntheticRequest to avoid re-reading consumed stream - /compile/container: same inline Turnstile fix; replace c.req.raw.body with JSON.stringify(c.req.valid('json')) since body is consumed by zValidator - configuration.routes.ts: - /configuration/resolve: replace turnstileMiddleware() with verifyTurnstileInline() + buildSyntheticRequest; remove now-unused turnstileMiddleware import - hono-app.ts: restore cache() middleware on /api/version and /api/schemas Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/70cde4c8-0bba-4bdc-99ee-e96fd7d5300e Co-authored-by: jaypatrick <1800595+jaypatrick@users.noreply.github.com> --- worker/hono-app.ts | 5 +++-- worker/routes/compile.routes.ts | 30 +++++++++++++++++++-------- worker/routes/configuration.routes.ts | 10 ++++++--- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/worker/hono-app.ts b/worker/hono-app.ts index dcab856aa..8956c1883 100644 --- a/worker/hono-app.ts +++ b/worker/hono-app.ts @@ -34,6 +34,7 @@ import { endTime, startTime, timing } from 'hono/timing'; import { prettyJSON } from 'hono/pretty-json'; import { compress } from 'hono/compress'; import { logger } from 'hono/logger'; +import { cache } from 'hono/cache'; import { OpenAPIHono } from '@hono/zod-openapi'; // Types @@ -389,8 +390,8 @@ async function handleApiMeta(c: AppContext): Promise { } app.get('/api', handleApiMeta); -app.get('/api/version', handleApiMeta); -app.get('/api/schemas', handleApiMeta); +app.get('/api/version', cache({ cacheName: 'api-version', cacheControl: 'public, max-age=3600' }), handleApiMeta); +app.get('/api/schemas', cache({ cacheName: 'api-schemas', cacheControl: 'public, max-age=3600' }), handleApiMeta); app.get('/api/deployments', handleApiMeta); app.get('/api/deployments/*', handleApiMeta); app.get('/api/turnstile-config', handleApiMeta); diff --git a/worker/routes/compile.routes.ts b/worker/routes/compile.routes.ts index 2e4e40400..54d2b623a 100644 --- a/worker/routes/compile.routes.ts +++ b/worker/routes/compile.routes.ts @@ -111,19 +111,19 @@ compileRoutes.post( '/ast/parse', bodySizeMiddleware(), rateLimitMiddleware(), + turnstileMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', AstParseRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleASTParseRequest(c.req.raw, c.env), + (c) => handleASTParseRequest(buildSyntheticRequest(c, c.req.valid('json')), c.env), ); compileRoutes.post( '/validate', bodySizeMiddleware(), rateLimitMiddleware(), + turnstileMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', ValidateRequestSchema as any, zodValidationError), - turnstileMiddleware(), (c) => handleValidate(buildSyntheticRequest(c, c.req.valid('json')), c.env), ); @@ -160,8 +160,12 @@ compileRoutes.post( rateLimitMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', CompileRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleCompileAsync(c.req.raw, c.env), + async (c) => { + // deno-lint-ignore no-explicit-any + const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); + if (turnstileError) return turnstileError; + return handleCompileAsync(buildSyntheticRequest(c, c.req.valid('json')), c.env); + }, ); compileRoutes.post( @@ -170,8 +174,12 @@ compileRoutes.post( rateLimitMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', BatchRequestAsyncSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleCompileBatchAsync(c.req.raw, c.env), + async (c) => { + // deno-lint-ignore no-explicit-any + const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); + if (turnstileError) return turnstileError; + return handleCompileBatchAsync(buildSyntheticRequest(c, c.req.valid('json')), c.env); + }, ); compileRoutes.post( @@ -180,8 +188,10 @@ compileRoutes.post( rateLimitMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', CompileRequestSchema as any, zodValidationError), - turnstileMiddleware(), async (c) => { + // deno-lint-ignore no-explicit-any + const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); + if (turnstileError) return turnstileError; if (!c.env.ADBLOCK_COMPILER) { return c.json({ success: false, error: 'Container binding (ADBLOCK_COMPILER) is not available in this deployment' }, 503); } @@ -194,12 +204,14 @@ compileRoutes.post( // Note: the URL hostname/scheme is irrelevant for DO stub.fetch() — the stub // intercepts the call and routes it to the container's internal server. // The path '/compile' maps to the POST /compile handler in container-server.ts. + // Body is re-serialised from the validated data because zValidator consumed + // c.req.raw.body above (cannot re-read a consumed ReadableStream). method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Container-Secret': c.env.CONTAINER_SECRET, }, - body: c.req.raw.body, + body: JSON.stringify(c.req.valid('json')), }); const containerRes = await stub.fetch(containerReq); return new Response(containerRes.body, { diff --git a/worker/routes/configuration.routes.ts b/worker/routes/configuration.routes.ts index 75d9488d2..91f25219d 100644 --- a/worker/routes/configuration.routes.ts +++ b/worker/routes/configuration.routes.ts @@ -17,7 +17,7 @@ import type { Env } from '../types.ts'; import type { Variables } from './shared.ts'; import { buildSyntheticRequest, verifyTurnstileInline, zodValidationError } from './shared.ts'; -import { bodySizeMiddleware, rateLimitMiddleware, turnstileMiddleware } from '../middleware/hono-middleware.ts'; +import { bodySizeMiddleware, rateLimitMiddleware } from '../middleware/hono-middleware.ts'; import { handleConfigurationDefaults, handleConfigurationResolve, handleConfigurationValidate } from '../handlers/configuration.ts'; import { ConfigurationValidateRequestSchema, ResolveRequestSchema } from '../handlers/configuration.ts'; @@ -62,6 +62,10 @@ configurationRoutes.post( rateLimitMiddleware(), // deno-lint-ignore no-explicit-any zValidator('json', ResolveRequestSchema as any, zodValidationError), - turnstileMiddleware(), - (c) => handleConfigurationResolve(c.req.raw, c.env), + async (c) => { + // deno-lint-ignore no-explicit-any + const turnstileError = await verifyTurnstileInline(c, (c.req.valid('json') as any).turnstileToken ?? ''); + if (turnstileError) return turnstileError; + return handleConfigurationResolve(buildSyntheticRequest(c, c.req.valid('json')), c.env); + }, );