Skip to content

Relocate the front-door router into agentrelay.com (Phase 2) - #2

Merged
willwashburn merged 59 commits into
mainfrom
relocate-router
Jun 23, 2026
Merged

willwashburn merged 59 commits into
mainfrom
relocate-router

Conversation

@willwashburn

@willwashburn willwashburn commented Jun 23, 2026 •

Copy link
Copy Markdown
Member

Moves the Cloudflare router that fronts agentrelay.com out of cloud's SST app
into this repo (full git history preserved), deployed via plain Wrangler as a
sibling to web/. Co-locates the front door with the web product and frees
router changes from cloud's gated full-SST production deploy.

Scope: prod only. Cloud keeps deploying the staging/dev routers; those retire
in Phase 3 (domain consolidation).

What's here

  • router/ — index.ts + src/ + tests, lifted from cloud/packages/router.
    61 tests pass, typecheck clean, wrangler deploy --dry-run bundles it.
  • router/wrangler.jsonc — binds the cloud-owned resources cross-repo by
    name/id: CLOUD_WEB_WORKER service (cloud-web-worker), ROUTER_CONFIG +
    RATE_LIMIT_COUNTERS KV, TRAFFIC_RECORDER R2, plus the CLOUD_APP_ORIGIN /
    FILE_OBSERVER_ORIGIN / WEBHOOK_WORKER_ORIGIN vars.
  • .github/workflows/deploy-router.yml — wrangler deploy on router/ changes.
  • router added as a workspace member.

⚠️ Before this can deploy — fill 3 cloud-owned values in router/wrangler.jsonc

All in the same Cloudflare account; look them up once:

  • ROUTER_CONFIG KV id and RATE_LIMIT_COUNTERS KV id → wrangler kv namespace list (titles router-config, rate-limit-counters)
  • WEBHOOK_WORKER_ORIGIN → the cloud webhook worker's *.workers.dev URL (dashboard / wrangler deployments)

Rollout (after the ids are filled)

  1. Merge → deploy-router.yml deploys to router-next.agentrelay.com.
  2. Verify parity vs the live apex (/, /cloud, /observer*, rate-limit, recorder, webhook flag).
  3. Apex cutover (separate, low-traffic window): gate cloud's prod router off
    (cloud PR) → flip router/wrangler.jsonc route to agentrelay.com → wrangler deploy.

🤖 Generated with Claude Code


Summary by cubic

Relocates the Cloudflare front-door router for agentrelay.com into this repo under router/, deployed with wrangler for faster, independent releases. Also fixes webhook forward handling to avoid disturbing request streams and ensures 500 errors return JSON.

  • New Features

    • Adds router/ Worker with rate limiting, traffic recorder, redirect rewrites; tests pass and typecheck is clean.
    • Adds router/wrangler.jsonc with bindings: CLOUD_WEB_WORKER, WEBHOOK_WORKER, ROUTER_CONFIG, RATE_LIMIT_COUNTERS, TRAFFIC_RECORDER; vars CLOUD_APP_ORIGIN, FILE_OBSERVER_ORIGIN; prod KV IDs are pinned.
    • Adds .github/workflows/deploy-router.yml to wrangler deploy on router/** changes; root scripts include deploy:router.
    • Routing: apex fallback proxies to https://origin-web.agentrelay.com; /observer routing remains unchanged; webhook requests build forwarded bodies per-branch to avoid stream errors and break the /webhooks/nango self-forward loop; 500s return application/json.
    • Docs: updates router/README.md diagram for the relocation and clarifies the agent name.
  • Migration

    • Rollout:
      • Merge → auto-deploys to router-next.agentrelay.com
      • Verify parity with apex (/, /cloud, /observer*, rate-limit, recorder, webhook flag)
      • Cutover: disable cloud’s prod router, change route to agentrelay.com in router/wrangler.jsonc, then wrangler deploy

Written for commit 4b06f49. Summary will update on new commits.

Review in cubic

willwashburn and others added 30 commits March 5, 2026 07:38
Rename getOrigin to matchRoute and return both origin and matched prefix. Update request handling to use the returned origin and to strip the proxy prefix from url.pathname (falling back to "/"), while preserving the default origin from env when no prefix matches. This enables forwarding to proxied origins with proper path rewriting.
The observer dashboard now uses basePath: /observer, so the worker
should pass the path through as-is.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a new Footer component and include it on the home and examples pages. Simplify Header by removing the showGitHubStars prop, always fetching GitHub stars, and adding a Star icon alongside the GitHub link. Add a new proxy route "/openclaw" in the router. Update favicon color to #00d9ff. Remove the AgentRelayRouter entry from sst-env.d.ts typings.
Add a new Footer component and include it on the home and examples pages. Simplify Header by removing the showGitHubStars prop, always fetching GitHub stars, and adding a Star icon alongside the GitHub link. Add a new proxy route "/openclaw" in the router. Update favicon color to #00d9ff. Remove the AgentRelayRouter entry from sst-env.d.ts typings.
Orchestrator: move workflow routes to packages/web, clean up artifacts
The Cloudflare router rewrites the hostname to the CloudFront origin
before proxying, so the Next.js app sees the CloudFront URL instead of
agentrelay.dev. This causes the OAuth redirect_uri to not match what's
configured in Google Cloud Console.

Fix: forward X-Forwarded-Host from the router and use it in the auth
routes to construct the correct public origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…main

Redirect responses from the CloudFront origin contain Location headers
pointing to the CloudFront hostname. Without rewriting, the browser
would follow redirects to the internal CloudFront URL instead of
agentrelay.dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Create a URL object for the origin and use its hostname when adjusting the proxied URL. Capture the incoming request Host once (requestHost) and set X-Forwarded-Host to that value. Remove the duplicate requestHost declaration and use originUrl.hostname when comparing redirect Location hostnames, replacing them with the original request host when rewriting Location. This ensures the original Host is preserved in headers and redirect rewrites are consistent.
Add X-Original-Host to proxied requests in the router and update Google auth routes to prefer it (falling back to X-Forwarded-Host, then url.origin). This ensures redirect_uri/origin is computed from the original host reported by a proxy/load-balancer, avoiding mismatches during OAuth flows. Also removes debug logging from the auth start route.
Move the /cloud path check before the apex-host split so any request whose pathname matches /cloud* returns NEXT_PUBLIC_APP_URL regardless of hostname. Update tests to assert cloud routes resolve to the cloud app on any host and add coverage that non-apex hosts still use the fallback proxy for non-cloud paths.
Apply Agent Relay brand system across web app
Refactor environment/domain handling for the cloud web and router. infra/web.ts now computes routerDomain, originDomain and appUrl (with local dev fallback), configures the Next.js site to use the origin domain via Cloudflare DNS, and exposes NEXT_PUBLIC_APP_URL and S3_BUCKET to the app. The Cloudflare Worker router is only created in non-dev stages and uses routerDomain; it now receives CLOUD_APP_ORIGIN as its environment variable. packages/router/index.ts updated to read CLOUD_APP_ORIGIN instead of NEXT_PUBLIC_APP_URL. sst.config.ts returns appUrl directly (removed previous web/url and DB helper metadata). Tests were updated to reflect the new env var name and canonical domain (removed www prefix) and to assert the router routes to the cloud origin.
Add support for an asset origin and improve cloud base-path handling across the router and web app.

- infra/web: expose NEXT_PUBLIC_ASSET_ORIGIN to Next.js and add assetOrigin constant.
- packages/web/lib/base-path: add getConfiguredAssetOrigin() and withAssetOrigin() to build absolute asset URLs when NEXT_PUBLIC_ASSET_ORIGIN is set; keep withBasePath for routed pages.
- packages/web/next.config.ts: set assetPrefix when asset origin is configured and add a rewrite to expose /cloud/:path* as /:path* for the app.
- packages/router: introduce strip/add path prefix helpers, getUpstreamPath() and rewriteLocation() to properly strip /cloud for upstream requests and rewrite Location headers back to the mounted public path; include X-Forwarded-Prefix header.
- UI updates: use withBasePath for internal links and withAssetOrigin for the favicon in layout.
- Tests: extend base-path and router tests to cover asset origin behavior, upstream path stripping, and redirect rewriting.

These changes allow proxied deployments where the app is mounted under /cloud while assets may be served from a separate origin, ensuring redirects and links are rewritten correctly.
Previously only /cloud/* was routed to the Next.js cloud app.
API routes at /api/v1/* fell through to the legacy proxy (agentrelay.net),
causing 404s for workspace, CLI auth, and webhook endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Will's review: API routes should go through /cloud/api/*, not /api/*.
The /cloud prefix is the established routing convention for the cloud app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: route /api/* through cloud app in edge router
Update the primary domain from agentrelay.dev to agentrelay.com across the codebase. Changes include email sender/DMARC, router domain and routing logic, Next.js app config (app URL and PostHog host), CLI default API URL, default OpenAPI server, footer contact/status links, README diagrams, and corresponding test expectations. This aligns production config and tests with the new .com apex.
khaliqgant and others added 16 commits May 3, 2026 10:41
Cloudflare Workers with `nodejs_compat` can hoist a bare `fetch(url, init)`
identifier off `globalThis`, throwing `TypeError: Illegal invocation: function
called with incorrect 'this' reference`. Convert all bare-fetch call sites in
Worker-running packages to `globalThis.fetch(...)` and ensure error paths
consume the response body before throwing.

Sites changed:
- packages/core/src/clone/github-clone-production.ts: fetchGithubTarballDirect
- packages/relaycron/src/engine/executor.ts: webhook delivery
- packages/router/index.ts: edge proxy subRequest
- packages/specialist-worker/src/specialist/agent-card.ts: fetchSpecialistCard
  (also added body cancel on the !response.ok throw path)

Per sage `.claude/rules/workers-fetch.md`.

Co-authored-by: GitHub Clone Durable Queue Workflow <agent@agent-relay.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mant) (#629)

* chore(migration): select Workers + add wrangler devDep

Two things this PR does:

1. Lands docs/spikes/cf-spike-report.md with **RECOMMENDATION: WORKERS**
   as an operator decision. The cf-spike workflow (cloud#614, closed)
   surfaced one real defect (missing wrangler devDep) but its other
   probes cascade-failed, producing a conservative FARGATE auto-rec.
   Operator has accepted the Workers path; the remaining unknowns
   (SSE behavior, bundle size, node:* surface) become Phase 3 work
   items gated by the cutover spec's Layer 4.

2. Adds wrangler as devDep in packages/web/ — required peer of
   @opennextjs/cloudflare. Unblocks Phase 3 builds.

Downstream phase workflows grep docs/spikes/cf-spike-report.md for the
RECOMMENDATION: WORKERS line; this PR satisfies that gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add migration-phase1-hyperdrive workflow (pre-run snapshot)

Self-contained version (no applyCloudRepoSetup) — single-line bash to
avoid the helper's JSON.stringify multi-line quoting bug. Stacked on
chore/migration-cf-decision (#615) so the spike-report gate passes
without waiting for that PR to merge.

* chore: document why Phase 1 setup-branch does NOT base on origin/main

Per PR #616 review feedback, setup-branch in the emergency-throttle
workflow now bases on origin/main. Phase 1 is stacked on
chore/migration-cf-decision (#615), so applying the same change here
would drop the spike report from the working tree and fail preflight.
Adding a comment explaining the divergence so future readers don't
'fix' it.

No functional change.

* feat(migration): Phase 1 — Hyperdrive in front of RDS (additive)

Stands up sst.cf.Hyperdrive pointed at the existing RDS instance and
adds a Workers-compatible DB client behind a selectDbClient() factory.
Lambda continues to use the existing TCP client unchanged.

Changes:
- infra/hyperdrive.ts (new): sst.cf.Hyperdrive bound to existing appDatabase
- packages/web/lib/db/client-hyperdrive.ts (new): postgres-js + Drizzle over Hyperdrive
- packages/web/lib/db/index.ts: selectDbClient(env) factory; HYPERDRIVE binding → Workers client, else TCP
- packages/web/lib/boot/resource-check.ts: REQUIRED_RESOURCES gains CloudDb binding entry

Stacked on chore/migration-cf-decision (#615). Spike report gate passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase1): address PR #620 review — use sst.cloudflare.Hyperdrive namespace

Devin review flagged that `sst.cf.Hyperdrive` is not a valid namespace —
the Cloudflare provider is registered as `cloudflare` in `sst.config.ts`,
so SST generates `sst.cloudflare.*` (matches every other Cloudflare
resource in `infra/`: Worker, D1, Kv, Queue, Bucket, Cron). `sst.cf`
would fail at deploy time when Pulumi tries to instantiate the resource.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase1): address PR #620 review — defer CloudDb boot check until link lands

Devin review flagged that adding `{ name: "CloudDb", kind: "binding" }`
to REQUIRED_RESOURCES before the Hyperdrive resource is wired into
`sst.config.ts` and linked into `infra/web.ts` would cause
`[boot] resource binding check FAILED` on every Lambda cold start. The
false alarm would pollute CloudWatch and mask real missing-resource
failures.

Per `.claude/rules/sst-resource-registration.md` step 7, the boot-check
entry should only land in the same commit that also wires `link: [...,
hyperdrive]`. Removing the entry now and leaving a NOTE pointing at the
follow-up so the next phase doesn't forget to re-add it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase1): address PR #620 review — cache Hyperdrive client in selectDbClient

CodeRabbit review flagged that `selectDbClient(env)` would call
`createHyperdriveDb` on every invocation, leaking postgres connection
pools when Worker handlers start using it in Phase 2+.

Cache the Drizzle client at module scope keyed by the binding identity.
In production `env.HYPERDRIVE` is a stable Worker binding so the
identity check is a fast-path; in tests that swap envs the identity
guard rebuilds correctly. Adds `resetSelectDbClientForTests` for tests.

The fallback path (`getCoreDb()`) is unchanged — `@cloud/core` already
caches its core client internally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add migration-phase2-webhook-worker workflow (pre-run snapshot)

Self-contained (no applyCloudRepoSetup) — single-line bash, plain string
concat for bash variable interpolation, .onError('fail-fast'), uses
sst.cloudflare.* (not sst.cf.*) per #620 review.

Stacked on feat/migration-phase1-hyperdrive so the webhook Worker has
the Hyperdrive resource to bind to.

* feat(migration): Phase 2 — webhook Worker + CF Queue (KV-flagged, dormant)

Moves /api/v1/webhooks/* off the OpenNext Lambda onto a dedicated
Cloudflare Worker. Router rule is KV-flagged (WEBHOOK_ORIGIN). Default
is lambda — ships dormant. Operator flips via:

  wrangler kv:key put --binding=ROUTER_CONFIG WEBHOOK_ORIGIN worker --remote

Deliverables:
- packages/webhook-worker/ — CF Worker entry + per-provider handlers
  (nango, github, composio, hookdeck) with HMAC verify ported verbatim
  from the Lambda routes. miniflare tests (13/13 passing).
- infra/webhook-queue.ts  — sst.cloudflare.Queue resource
- infra/webhook-worker.ts — sst.cloudflare.Worker linked to hyperdrive + queue + provider secrets
- infra/web.ts            — router worker now binds ROUTER_CONFIG KV + WEBHOOK_WORKER_ORIGIN env
- packages/router/index.ts — KV-flagged forward to the webhook Worker (default: lambda)

Stacked on feat/migration-phase1-hyperdrive (#620). Uses sst.cloudflare.*
(not sst.cf.*) per #620 review correction.

Eliminates webhook contention with the user-facing tier. A Nango/GitHub
webhook fan-in storm can no longer 429 dashboard requests once flipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase2): address PR #629 review — exact-path match for webhook worker

Devin flagged that the router's webhook prefix match would capture the
Composio OAuth callback (/api/v1/webhooks/composio/connect/callback) when
WEBHOOK_ORIGIN=worker, returning 404 instead of falling through to the
Lambda. Replace the prefix check in shouldUseWebhookWorker() with a
membership check against the exact set of paths the worker knows how to
handle (composio, github, hookdeck, nango). Any other sub-path under
/api/v1/webhooks/* keeps routing to the Lambda — preserving the OAuth
callback and any future Next.js routes that share the prefix.

Adds two router unit tests covering the regression: the four exact paths
match, and known sub-paths (notably composio/connect/callback) do not.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: add migration-phase0-recorder workflow (pre-run snapshot)

Self-contained version — inlines setup-branch + install-deps to avoid
applyCloudRepoSetup's multi-line bash quoting bug. Committing pre-run so
ricky auto-fix mutations are recoverable via git checkout.

* feat(migration): Phase 0 — production traffic recorder + R2

Adds NDJSON request/response capture to the existing CF router worker.
Redaction, deny-list for secret-bearing routes, 14-day R2 lifecycle.
KV-tunable sample rate (default 100%).

Additive — no routing behavior change. Feeds Phase 3 Layer 3 replay corpus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t + foundation + rate limit + ACU bump + writeback backfill + docs (#647)

* feat(migration): Phase 0 foundation — acceptance suite + replay harness + handler tests (salvage)

Salvages the Phase 0 foundation work that the
`fargate-migration-phase0-foundation` workflow generated but failed to commit
when its verify gates died on a bash quoting bug (unrelated to the agent
output). The deliverables themselves are sound and gate Phase 3 cutover, so
they're landing here in a clean PR from origin/main.

Brings in:
- `packages/acceptance/` — Layer 1 contract suite scaffold + 42 acceptance
  `.test.ts` files across auth / workspaces / integrations / runtime / api,
  plus helpers (auth, fixtures, env, runtime, server) and fixture seeds.
  Intended to run against deployed environments via `ACCEPTANCE_BASE_URL`
  (not exercised in this PR — see test plan).
- `packages/replay-harness/` — Layer 3 replay oracle: CLI, corpus reader,
  S3-backed replay engine, equivalence comparator, report writer, plus 2
  unit tests covering equivalence + replay-smoke. Used by Phase 3 to prove
  Worker responses match Lambda for a captured corpus.
- `packages/web/test/handlers/` — Layer 2 integration tests: 45
  handler-coverage `.test.ts` files (many are thin re-exports of existing
  top-level tests with `// @handler` route headers so coverage tooling can
  enforce one integration test per route).
- `packages/web/test/helpers/{pglite-db,localstack,boot-app}.ts` — shared
  helpers for in-memory Postgres + LocalStack-backed tests.
- `scripts/check-route-coverage.mjs` + `scripts/check-handler-coverage.ts`
  + `scripts/route-coverage-allowlist.json` — coverage gates that enforce
  every `/api/v1/...` route has a corresponding handler test (or an
  allowlist entry).

Supporting changes:
- Add `undici` to root devDependencies (used by acceptance helpers).
- Add `allowImportingTsExtensions: true` to `packages/web/tsconfig.json`
  to match root tsconfig — the handler-coverage re-exports drag in top-level
  `tests/*.ts` files that use explicit `.ts` import extensions.
- Update `tests/github-webhook-route.test.ts` `loadRoute` signature to
  `NextRequest`/`NextResponse` so the route module typechecks cleanly when
  pulled in via the new handler-coverage re-export.

Validation in this PR (run in the new worktree):
- `packages/replay-harness`: `tsc --noEmit` clean; vitest 2 files / 5 tests
  PASS.
- `packages/acceptance`: `tsc --noEmit` clean. Acceptance suite NOT run —
  requires `ACCEPTANCE_BASE_URL` + a test workspace; landing as code only.
- `packages/web/test/handlers`: `tsc --noEmit` clean (under web tsconfig);
  vitest 279/280 pass. One flaky test
  (`workflow-run-response-pushed-to.test.ts > merges disjoint pushedTo keys
  when two updates fire concurrently`) times out under parallel load but
  passes in isolation — to investigate as a follow-up.
- Root `tsc --noEmit`: 0 new errors introduced by salvaged code (13
  remaining errors pre-exist on origin/main and are unrelated).

What's NOT in this PR / known follow-ups:
- Acceptance suite has not been executed against a real environment; only
  typechecked. Wiring `ACCEPTANCE_BASE_URL` into CI is a follow-up.
- The flaky concurrent-update handler test needs a higher timeout or a
  serial fixture.
- Pre-existing root typecheck errors (`packages/web/lib/integrations/
  nango-sync-queue.ts`, `relayauth/packages/server/src/__tests__/
  test-helpers.ts`) are not addressed here — they exist on main.

Spec: `docs/fargate-migration-spec.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(migration): Hyperdrive network-path spec (Phase 3 prereq)

Phase 3 of the cloud-web migration moves web onto Cloudflare Workers, which
can only reach Postgres via Hyperdrive. Hyperdrive runs at CF's edge and
connects out to a Postgres endpoint over the public internet; our Aurora
cluster lives on private VPC subnets and is not reachable today. This spec
lays out three options for bridging that gap (public Aurora endpoint with
allowlist; PrivateLink + Cloudflare Tunnel; public read replica with
private writer), a decision matrix, recommended starting point (Option A,
subject to security signoff), risks, and rollout plan.

Status: Proposed. Decision belongs to a human reviewer with security sign-off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add migration-hyperdrive-option-b workflow (pre-run snapshot)

Self-contained workflow for Hyperdrive Option B infrastructure:
CF Tunnel + Access app + service token + cloudflared Fargate daemon +
real Hyperdrive resource replacing the null placeholder. All declarative
via @pulumi/cloudflare.

Patterns: inlined setup-branch + install-deps, single-line bash, plain
string concat (no template-literal traps), .onError('fail-fast').

* chore: add migration-phase3-web-worker-build workflow (pre-run snapshot)

Self-contained workflow for the Phase 3 cloud-web Worker build:
@opennextjs/cloudflare build of packages/web, wrangler.toml bindings,
sst.cloudflare.Worker resource, KV-flagged router rule. Ships dormant
(KV default: lambda).

* fix(workflow): relax preflight; install @opennextjs/cloudflare in-workflow

Original preflight required @opennextjs/cloudflare to be preinstalled
as a precondition, but that dep should be installed BY the workflow,
not before it. Removed the preinstall check + the strict ALLOWED_DIRTY
gate; added an explicit install-opennext-cf step. The standalone-output
check moves into the fix-build agent loop where the agent can patch
next.config.ts if needed.

* fix(workflow): remove false-positive verify rules; rely on claude reviewer

The 'no process.env' shell regex was a false positive — process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID
is the canonical pattern in this repo (infra/sage.ts, infra/traffic-recorder.ts,
infra/cloudflare-worker-migrations.ts all use it). The 'publicly_accessible'
regex also fired spuriously due to set -e + || subshell interaction.

The semantic checks (no secrets via process.env, Aurora stays private) are
already enumerated in the final-review step's task prompt for the claude
reviewer agent — keep them there where judgement can apply, drop the
brittle regex.

* fix(acceptance): retry 503 + correct stale auth-error contract

Resolves five categories of failures from running the suite against
current prod:

- helpers/server.ts now retries 503 as well as 429 with exponential
  backoff. Prod is in throttle recovery (Lambda URL OAC was just
  hardened in #631) and the suite shouldn't fail PRs while the edge
  works through the backlog.
- integrations/{github,linear,notion}-query.test.ts and
  integrations/proxy-slack.test.ts now assert 401 (the unified auth
  envelope) instead of the historical 403. The bearer-auth helper was
  unified months ago; the tests were encoding pre-unification behavior.
- workspaces/workspaces-join.test.ts now correctly asserts that
  anonymous workspaces ignore a forged bearer (the existing route
  contract — anon join is open) instead of asserting an impossible 401.
- runtime/{agents,deploy}.test.ts no longer assert exact substring
  "Internal Server Error" in non-JSON error bodies — CloudFront /
  Lambda edge pages vary; we now just assert a non-empty body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coverage): real handler-coverage gate + issue-tracked allowlist

The Phase 0 coverage gates were paper: check-handler-coverage.ts only
grep'd for `// @handler` comment markers, and check-route-coverage.mjs
already had a `reason` field but no `issue` requirement so deferrals
weren't actionable. Three 2-line handler-test files were also
re-exporting node:test suites that the vitest runner can't see.

This commit:

- Rewrites scripts/check-handler-coverage.ts to validate every claimed
  `@handler` route by walking the file's import graph (static, dynamic,
  and the bare-string-literal-to-route.ts pattern node:test handler
  shims use) and confirming (a) at least one reachable file imports
  vitest, and (b) at least one reachable file lives under the route's
  directory in packages/web/app/api/. Files that satisfy only the
  `@handler` marker but never load any of the route's source fail.
- Adds destructured-export detection (`export const { POST } = ...`) to
  the stateful-route walker so factory-pattern routes like
  /api/v1/agents/deploy are no longer silently dropped.
- Adds `issue` as a required allowlist field in both gates. Deferred
  coverage now requires a tracking reference, not just a freeform
  reason. (route gate previously only required `reason`.)
- Deletes 3 handler test files that re-exported node:test suites:
  github-clone-route, proactive-deploy-routes, workspace-secrets-routes.
  Their underlying tests still run via npm run github:validate /
  orchestrator:test; the deletion just removes the false 0-test-claim.
- Adds allowlist entries for the 4 corresponding routes referencing
  tracking issue #638 (vitest-port follow-up).
- Adds allowlist entries for 10 routes added post-Phase-0 that have no
  acceptance coverage yet (digest-functions, gitlab, relayfile batch /
  audit) referencing tracking issue #639.
- Makes check-route-coverage.mjs fall back to walking
  packages/web/app/api/ when the generated .next/types/routes.d.ts is
  not present (local dev / pre-build CI invocation).
- Bumps the timeout on tests/workflow-run-response-pushed-to.test.ts's
  concurrent-merge test from the default 5s to 30s — the PR author
  had already flagged this as flaky under parallel load; the fix is
  trivial.

After this commit:
  $ node scripts/check-route-coverage.mjs
    Route coverage OK (181 route-method pairs checked).
  $ npx tsx scripts/check-handler-coverage.ts
    Handler coverage OK (41 stateful routes, 47 real-covered, 11 allowlisted).
  $ vitest run --dir packages/web/test/handlers
    280 passed (280), 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci+docs: wire phase0-tests job and document the suite

CI:
- Adds a `phase0-tests` job to .github/workflows/ci.yml that runs on
  every PR:
    1. node scripts/check-route-coverage.mjs       — acceptance gate
    2. npx tsx scripts/check-handler-coverage.ts   — handler gate
    3. vitest run --dir packages/web/test/handlers — Layer 2 (offline)
    4. vitest run (replay-harness)                 — Layer 3 (offline)
    5. vitest run (acceptance) against prod        — Layer 1
  Authenticated acceptance tests are it.skip'd when no credentials are
  provided, so the suite is safe to run on any PR without secrets.

Docs:
- Rewrites packages/acceptance/README.md from a layout stub into a
  living playbook: how-to-run, how-to-add-a-test-for-a-new-route,
  what-gets-enforced, when-tests-fail-in-CI, what-is-NOT-tested, and
  the two follow-up issues (#638, #639).
- Replaces packages/acceptance/AGENTS.md, which was full of stale
  workflow guidance, with a real agent guide: contract conventions,
  the // @route marker contract, allowlist rules, and a do-not list.
- Adds packages/web/test/handlers/README.md (didn't exist) with the
  two test-authoring patterns (in-file vitest vs. re-export), the
  coverage-gate contract, common failure modes, and open follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(migration): Hyperdrive Option B — Tunnel + Access + private Aurora

Phase 3 prerequisite. Wires up the full Hyperdrive-via-Cloudflare-Tunnel
infrastructure so Workers can reach Aurora WITHOUT exposing Aurora to
the public internet.

All declarative — no operator dashboard clicks (CF API token already
has Tunnel/Access/Hyperdrive scopes per operator setup).

Deliverables:
- infra/cloudflared-tunnel.ts  Tunnel + Access app + service token
- infra/aurora-tunnel-daemon.ts cloudflared Fargate task in VPC
- infra/hyperdrive.ts           real HyperdriveConfig (replaces null)
- infra/webhook-worker.ts       restore hyperdrive link

Spec: docs/migration/hyperdrive-network-path.md (PR #634)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(architecture): defense-in-depth doc for preventing single-client outages

Direct response to 2026-05-14 incident (issue #640): a single internal
client's fsnotify-driven retry loop saturated production for ~6.5h
because five independent rate-limit / backoff defense layers were
simultaneously missing.

This doc maps the seven defense layers, what each does, what's in
place, what's missing, and where each gap is tracked. Living
document — updates as defense layers land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(edge): per-key rate limit at Cloudflare Worker gateways

Closes the server-side rate-limit gap exposed by the 2026-05-14
relayfile-mount runaway loop. A single workspace hitting
/v1/workspaces/<id>/sync/status at ~30 req/sec exhausted the
upstream Lambda's 10-slot concurrency cap and 429'd unrelated
users; neither the cloud router nor the relayfile-api worker
had any per-key throttle in place.

Adds a shared KV-backed rate limiter to both edge Workers:
60 req/min per workspace (or user, or IP), 1000 req/min global,
tunable at runtime through the existing ROUTER_CONFIG KV
namespace. A RATE_LIMIT_DISABLED kill switch is available for
emergency rollback without a redeploy.

The limiter runs before any other routing in both Workers, so a
runaway key gets a 429 at the edge and never reaches CloudFront
or Lambda. Counters are bucketed by minute in a dedicated
RATE_LIMIT_COUNTERS KV namespace bound to both gateways, so a
client that hits both endpoints does not get to consume two
separate budgets.

Known limitation: KV is eventually consistent, so the effective
ceiling at high traffic is ~2-3x configured (180 req/min worst-
case for the 60 default). That's accepted for now — still ~100x
lower than the incident-day pattern. Upgrade path to Durable
Object atomicity is documented in docs/security/rate-limiting.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acceptance): validate redactions per secret match

* fix(acceptance): address review nits for fixtures and helpers

* feat(migration): Phase 3 — cloud-web Worker build (KV-flagged, dormant)

OpenNext-CF build of packages/web, wrangler.toml bindings, SST Worker
resource, KV-flagged router rule. Ships dormant — router defaults to
lambda; cutover is Phase 4.

Deliverables:
- packages/web/.open-next/worker.js (build artifact)
- packages/web/wrangler.toml (staging deploy + wrangler dev config)
- infra/web-worker.ts (sst.cloudflare.Worker for staging)
- packages/router/index.ts (cloudOrigin KV flag, defaults to lambda)

Spec: docs/fargate-migration-spec.md (Phase 3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase3): address PR #641 review — wire daemon, harden secret bindings, scope egress

Devin findings (both 🔴):
- BUG-0001: aurora-tunnel-daemon.ts imported `{ cluster, vpc }` but there was no
  ECS cluster declared anywhere. Added `sst.aws.Cluster("CloudCluster")` export
  to infra/vpc.ts so the tunnel daemon Fargate service has a cluster to land in.
- BUG-0002: aurora-tunnel-daemon.ts was never imported from sst.config.ts, so
  the daemon, SG ingress rule, and CW alarm would silently not deploy and the
  Hyperdrive → Aurora path would have no tunnel connector. sst.config.ts now
  imports both cloudflared-tunnel and aurora-tunnel-daemon explicitly.

CodeRabbit findings:
- Aurora SG resolution: removed the fragile `vpc.securityGroups[0]` fallback
  that would have attached the 5432 ingress rule to whatever SG happened to be
  first (NOT Aurora's). Now throws explicitly if
  appDatabase.nodes.cluster.vpcSecurityGroupIds is empty.
- Scoped cloudflared daemon SG egress from "all ports, all destinations" to
  443/tcp only — cloudflared only needs outbound HTTPS to CF's edge.

Other self-review fixes:
- infra/hyperdrive.ts: account ID now reads CLOUDFLARE_DEFAULT_ACCOUNT_ID
  directly (canonical pattern used by sage.ts, traffic-recorder.ts,
  cloudflare-worker-migrations.ts) instead of the over-clever
  `pulumi.all([tunnel.accountId, serviceToken.accountId])` chain that bought
  nothing but inconsistency.
- infra/webhook-worker.ts: GITHUB_WEBHOOK_SECRET and COMPOSIO_WEBHOOK_SECRET
  bindings are now conditional via spread — omit the binding entirely when the
  env var is unset, rather than binding empty strings. An HMAC compared against
  "" would pass any signature; the prior behavior was a security hole the
  moment the KV flag flipped. Cutover preflight must verify both are populated.
- infra/cloudflared-tunnel.ts: added comment block before accessApplication
  documenting the `<tunnelId>.cfargotunnel.com` domain assumption and the
  CNAME-from-owned-zone fallback if CF rejects it at deploy time.

Workflow side-effects also committed:
- infra/relayfile.ts, infra/relaycron.ts, infra/sage.ts,
  infra/cataloging-agent-{github,linear}.ts, infra/credential-proxy.ts,
  infra/relayauth.ts: hoisted scriptName constants to module top instead of
  reading `<worker>.nodes.worker.scriptName` after construction. The latter
  pattern can trip a Pulumi cycle for queue/cron consumers that reference the
  producing worker's scriptName as part of their own binding.
- infra/sst-globals.d.ts + tsconfig change: typecheck shim so `infra/` builds
  before `sst install` materializes .sst/platform/config.d.ts. SST's own deploy
  regenerates config.d.ts at run time so this only affects local typecheck.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(edge): make rate-limit bypass list runtime-tunable via KV

Adds RATE_LIMIT_BYPASS_PREFIXES_EXTRA to ROUTER_CONFIG so operators
can whitelist additional paths from rate limiting without a deploy.
Static BYPASS_PREFIXES stays as the fail-safe (KV unreachable still
exempts health/observer/static), and the dynamic check fails open on
KV errors so a transient KV blip cannot start rate-limiting healthcheck
traffic. Per-isolate cache with 60s TTL keeps the hot path cheap;
propagation across edges is <60s plus KV's consistency window.

Now ALL rate-limit config (per-key limit, global limit, kill switch,
bypass list) is runtime-tunable via the same KV namespace.

Tests: 13 new — parseBypassExtras defensive behavior (null/empty/
whitespace/non-path/dedup), isBypassPathDynamic static+KV path,
cache TTL boundary, KV error fail-open, end-to-end via maybeRateLimit
showing a KV-whitelisted path bypassing a tight per-key limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(phase3): nodejs_compat on deployed Worker; consolidate wrangler config

Critical:
- infra/web-worker.ts: add `transform: { worker(args) { args.compatibilityFlags
  = ["nodejs_compat"] } }`. OpenNext's Next.js → Worker output requires the
  flag at runtime (it imports node:buffer / node:process / etc). Without it,
  the deployed Worker boots fine but crashes on the first request touching a
  Node API. Setting it via SST's transform is the canonical pattern in this
  repo (see infra/webhook-worker.ts:63). The `compatibility_flags` line in
  packages/web/wrangler.toml only applies to local `wrangler dev`, NOT to the
  SST-deployed Worker — that file is consulted by Wrangler, not by SST.

Cleanup:
- Deleted packages/web/wrangler.jsonc. The PR shipped both a `.jsonc` and a
  `.toml` with conflicting `name` ("web" vs "cloud-web-staging") and
  conflicting `compatibility_date` (2026-04-15 vs 2026-05-14). Keep
  `wrangler.toml` as the single source of truth — it has the full set of
  bindings (Hyperdrive, KV, R2, Queues, vars) that the .jsonc was missing.
- packages/web/wrangler.toml: expanded the header comment to (a) call out that
  the `<set by ...>` placeholders are documentation, not valid IDs, and
  `wrangler dev` will refuse to start until they're replaced, and (b) make
  explicit that this file does NOT govern the SST-deployed Worker — the
  `transform` block in `infra/web-worker.ts` does.

Hyperdrive ternary in `link:` is intentionally left as-is — `hyperdrive` is
still the `null` placeholder from #632 on this branch until #641 merges and
this branch rebases. Removing the ternary now would break standalone builds
of this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(infra): bump prod Aurora max ACU 4→8 ahead of Workers Phase 3

Production cluster (cloud-production-appdatabasecluster) is currently
configured to scale 1-4 ACU and peaked at the ceiling (4 ACU) multiple
times in the 24h before this change. The Cloudflare Workers Phase 3
cutover will land more concurrent isolates against the same cluster
than the current Lambda path does, so the compute/IO ceiling needs to
move before that traffic shape arrives.

Doubling (4→8) is the minimum viable hedge — it doesn't change the min,
doesn't change preview/dev (which stay at 0-4 with auto-pause), and
Aurora Serverless v2 pricing is on-demand so the new ceiling is free
until it's actually reached.

Connection count is not the bottleneck (peak observed: 18 connections;
8 ACU = ~450 max_connections), and RDS Proxy continues to front the
cluster in prod with its existing pool config.

Followups (tracked separately, not in this PR):
- Hyperdrive resource + binding lands with the Phase 3 web Worker PR
- CloudWatch alarms on DatabaseConnections (>150) and ACU (>=7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acceptance): address remaining helper review defects

* chore(bundle): cherry-pick #623 — RelayFile writeback CF backfill

Brings in the 4 net-new files + the push.ts signature change without the
stale-base PR's 15k lines of accidental main reverts. See #623 for the
original review history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(phase3): address Devin findings on #647 — daemon egress to Aurora + acceptance retry body cancel

Devin BUG-0001 🔴 — tunnel daemon SG blocks egress to Aurora:5432.
The previous CodeRabbit-driven scoping of the daemon SG to 443/tcp egress
only covered the Cloudflare control-plane tunnel; it never permitted the
*data-plane* hop from cloudflared to Aurora. AWS SGs are stateful on the
inbound side, but the initial outbound TCP SYN still needs an explicit
egress rule. Tunnel would have established to CF cleanly and every
Hyperdrive query would have timed out at the daemon-→-Aurora hop.

Added `auroraTunnelDaemonEgressToAppDatabaseRule` — 5432/tcp egress
scoped to Aurora's SG specifically (NOT 0.0.0.0/0). Updated the SG def
comment to reference both hops.

Devin BUG-0002 🟡 — acceptance retry helper canceled the response body
before returning on the last attempt, so callers got a response with a
disturbed stream and `.json()` / `.text()` threw "stream already
canceled". Swapped the order: check `attempt === MAX_ATTEMPTS - 1`
*first* and return the response untouched, only cancel the body when
we're going to retry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(phase3): address all 5 Codex findings on bundle PR #647

P1.1 🔴 — `infra/web-worker.ts` was declared but never imported in
`sst.config.ts`, so SST never materialized the CloudWebWorker resource.
Same root cause as Devin BUG-0002 on #641's aurora-tunnel-daemon. Added
`await import("./infra/web-worker")` to the import chain.

P1.1b 🔴 — Router's `env.CLOUD_WEB_WORKER` service binding was never
declared on the AgentRelayRouter in `infra/web.ts`. `cloudOrigin=workers`
would have been a silent no-op falling back to Lambda. Added the
service binding via `appendWorkerBindings`. Also hoisted
`cloudWebWorkerScriptName` const in web-worker.ts (matches the pattern
sage / relayauth / relayfile-api use) so the binding can reference a
known name at construction time instead of reading it post-creation.

P1.2 🔴 — Hyperdrive private-DB flow now matches CF docs:
  - Real public hostname: `aurora-db.<stage>.agentrelay.cloud` (internal
    zone, matches the catalog-* pattern already in use).
  - `cloudflare.DnsRecord` CNAMEs it to `<tunnelId>.cfargotunnel.com`
    with `proxied: true`.
  - Tunnel ingress now declares the hostname explicitly (with a
    `http_status:404` catch-all per CF requirement).
  - `ZeroTrustAccessApplication.domain` uses the real hostname,
    `dependsOn: [auroraDbDnsRecord]` so DNS is in place first.
  - `HyperdriveConfig.origin.host` uses the same hostname,
    `dependsOn: [accessApplication]` so the Access app exists when
    Hyperdrive validates the service-token credentials at create time.
The earlier `<tunnelId>.cfargotunnel.com` pattern is for IP-routed
private networks, not the hostname-routed Access flow Hyperdrive needs.

P1.3 🔴 — Added `npx @opennextjs/cloudflare@1.19.10 build` step to
`_deploy-cloud-stage.yml` so `packages/web/.open-next/worker.js`
actually exists at `sst deploy` time. The existing `@opennextjs/aws`
build produces a different `.open-next/` layout that SST's
CloudWebWorker handler path cannot find.

P1.4 🟠 — Added `/api/v1/webhooks` to `BYPASS_PREFIXES` in
`packages/router/src/rate-limit.ts`. Webhook ingress has no auth
token and no workspace path, so it would bucket by IP and trip the
60/min cap with even modest provider burst traffic. Webhook worker
already has queue-backed backpressure.

P1.5 🟠 — Gated the POST `/api/waitlist` happy-path test in
`packages/acceptance/src/api/public-routes.test.ts` behind
`ACCEPTANCE_WRITES_OK=1`. Default OFF on PR CI so the test stops
writing real rows to the prod waitlist on every PR run. The 400-case
and GET tests still run unconditionally.

All findings credited to Codex review on PR #647. Local gates pass:
infra typecheck clean, 43 router rate-limit tests, 276 handler tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(rate-limit): key cookie-auth /cloud* traffic by session cookie, not IP

Cookie-authenticated requests to /cloud* had no workspace ID and no
Authorization header, so extractLimitKey() was falling through to the
IP bucket — collapsing all users behind a shared NAT into one rate
limit bucket.

Add session-cookie parsing (cookie name: agent_relay_session) as
precedence level 3, between auth-header and IP. The cookie value is
hashed with FNV-1a 32 and keyed as `session:<hex>` so the raw token
never appears in the counter store.

Precedence order is now:
  1. ws:<id>   — workspace path segment
  2. user:<hash> — Authorization header
  3. session:<hash> — agent_relay_session cookie (new)
  4. ip:<addr> — cf-connecting-ip / x-forwarded-for
  5. anon      — no signal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase3): Track D — make replay/backfill tooling safe-by-default

- backfill script now defaults to dry-run; requires explicit --apply to
  mutate (old --dry-run flag kept as an alias so existing invocations
  don't break); prints clear warnings in dry-run mode
- runbook fixes the non-existent npm script reference to the correct
  `npx tsx scripts/...` invocation via sst shell, and documents the
  --apply requirement with a quick-reference options table
- replay harness strips the /cloud mount prefix by default before
  building target URLs (configurable via stripMountPrefix option)
- replay harness skips POST/PUT/PATCH/DELETE unless --allow-mutations
  is passed; MutationBlockedError exported for programmatic use
- replay harness adds per-request timeout (default 30 s) and overall
  replay timeout (default 5 min) with AbortController + deadline guard
- 13/13 replay-harness tests pass (5 new tests cover the new behaviours)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(phase3): Track B — DB client factory, CF env propagation, S3 storage on Workers

B1: Add `packages/core/src/db/factory.ts` with `selectDbClient(env?)` that
routes to postgres-js/Hyperdrive when `env.HYPERDRIVE.connectionString` is set,
else falls back to the existing Lambda pg-pool path. Includes connection caching
and `setDbForTesting` override support. Add `factory.test.ts` (3 vitest cases).
Add `postgres` to `@cloud/core` deps.

B2: Update `packages/web/lib/db/index.ts` to make `getDb()` context-aware: reads
the Cloudflare context via `Symbol.for("__cloudflare-context__")`, calls
`selectDbClient({ HYPERDRIVE })` on Workers, and falls through to Lambda client
otherwise. No call-site changes (51 handlers unmodified).

B3: Add `packages/web/lib/storage.ts` with `createWorkflowStorageS3Client()` and
`readWorkflowStorageBucket()` that read credentials from Worker env bindings first,
then SST Resource, then process.env. Wire all S3 usage sites (artifact-store,
evidence-builder, bundle-store, digest-functions/store, github-push-back, 3 route
handlers) to use the centralized storage helpers. Delete `client-hyperdrive.ts`
(now redundant). Add `lib/cloudflare-context.ts` internal helper and
`scripts/patch-opennext-native-shims.mjs` for Worker build compat.

Verification: factory.test.ts 3/3 pass; handler tests 276/276 pass; infra tsc clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase3): Track A — alias ssh2/cpu-features to empty shim in CF Worker bundle

The Worker never invokes SSH code paths, but @agent-relay/sdk → @agent-relay/cloud
→ ssh2 → cpu-features pulls in a native .node binary that esbuild cannot bundle for
a Cloudflare Worker.

Three-layer fix:
1. next.config.ts outputFileTracingExcludes: prevents @vercel/nft from copying ssh2
   and cpu-features into the standalone output.
2. next.config.ts turbopack.resolveAlias + webpack: aliases ssh2 and cpu-features to
   OpenNext's empty shim during next build so the file tracer never sees the packages.
3. package.json postinstall runs scripts/patch-opennext-native-shims.mjs: patches
   bundle-server.js to add the same aliases in the OpenNext esbuild step (which does
   not read next.config.ts aliases).

Verified: npx @opennextjs/cloudflare@1.19.10 build exits 0 and produces worker.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase3): hard-error when --apply and --dry-run are passed together

Silently last-flag-wins was confusing and potentially dangerous for prod ops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase3): address Codex round-3 findings on bundle PR #647

P1.1 🔴 + P1.2 🔴 — CloudWebWorker missing bindings + environment.
The Worker resource only set scriptName + nodejs_compat. `link:` materializes
SST resources but does NOT generate the env.* bindings on the CF Worker.
So `env.HYPERDRIVE` was undefined, `getDb()` fell through to the Lambda
pg+Resource.AppDatabase path (which the Worker can't run), and process.env
reads (URLs, cookie domain, S3 bucket, webhook config) all returned blank.

Added explicit `appendWorkerBindings` for HYPERDRIVE, ROUTER_CONFIG,
RATE_LIMIT_COUNTERS. Mirrored infra/web.ts:148 environment block so the
Worker sees the same NEXT_PUBLIC_APP_URL / RELAYAUTH_URL / RELAYFILE_URL /
RELAY_LLM_PROXY / RELAYCRON_URL / CREDENTIAL_PROXY_URL / cookie domain /
SAGE_WEBHOOK_URL / S3_BUCKET / WEBHOOK_CONSUMERS_JSON / WEBHOOK_MSD_BACKEND_*
that Lambda gets. OpenNext-CF's init.js copies these into process.env on
first request, so the existing handler code reads them unchanged.

P1.4 🔴 — Acceptance CI still mutated prod beyond the waitlist gate.
`workspaces-crud.test.ts:82` POSTed `/api/v1/workspaces/create` and
`workspaces-join.test.ts:31` did the same in beforeAll, both unauthenticated.
The destroyWorkspace helper at `_helpers.ts:78` explicitly skips unauth
workspaces, so every PR CI run was leaking a workspaces row in prod.

Both now gated on `ACCEPTANCE_WRITES_OK=1` — same env var pattern as the
waitlist gate. crud uses `it.skipIf(...)`, join uses `describe.skipIf(...)`
since the whole suite hangs off the beforeAll write.

P1.5 🔴 — `npx tsx scripts/backfill-relayfile-writeback-cf.ts --help` was
broken at the entrypoint. The static named-import form failed at Node's
ESM parse time because packages/web has no `"type": "module"`, so tsx
loaded the .ts via CJS shape and exposed exports under `default`. Node
ESM rejected the named binding before main() ever ran.

Switched to dynamic `await import(...)` inside main() with a guard that
accepts both module shapes. Runbook command from
`docs/operations/relayfile-writeback-cf-backfill.md` now works end-to-end.

P2.6 🟠 — Recorder bypassed the cloud-web Worker path.
`packages/router/index.ts:256` returned `env.CLOUD_WEB_WORKER.fetch()`
early, BEFORE the `recordingRequest = request.clone()` at line 288. So
once cloudOrigin flipped to "workers", the replay harness would have no
/cloud Worker corpus — exactly the wrong moment to lose recording, since
that's when we need replay evidence to prove cutover equivalence.

Moved the recorder clone to the top of fetch(), kept the existing Lambda
path's `recordingRequest` symbol pointing at the same clone so the
Lambda-origin code path is unchanged. Each early-return branch now
ctx.waitUntil(maybeRecord(...)) the cloned request + response. Both the
cloud-web Worker AND webhook Worker paths now capture.

P1.3 (STS-on-Worker) acknowledged out of scope — 6 routes still call
Resource.WorkflowStorage.stsRoleArn and can't trivially run on a Worker
without a separate credential broker. Documented as known gap in the
previous bundle review comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(phase3): partial Codex round-4 — WORKER_SELF_REFERENCE + acceptance auth-session gate

P1.1 partial 🟠 — Added a `service` binding for WORKER_SELF_REFERENCE
pointing at the Worker's own script name. OpenNext-CF emits
revalidation / on-demand-ISR paths that use this binding to call back
into the Worker without going through the CF edge. Matches the binding
documented in packages/web/wrangler.toml.

The sibling ASSETS binding is intentionally NOT added in this PR — it
requires the `cloudflare.WorkersAssetsUpload` resource flow (hash each
file in .open-next/assets/, upload via CF API, obtain completion JWT,
pass to WorkersScript.assets). SST doesn't expose this directly; it's a
substantial workstream tracked as a follow-up to scope the cutover.
Without ASSETS, the Worker still boots and serves regular requests —
just can't serve `/cloud/_next/static/*` images through the Worker
path. The bundle ships dormant so this doesn't affect current traffic.

P2 🟢 — Auth-session test now requires explicit ACCEPTANCE_ALT_WORKSPACE_ID
when running session-authed. Previously fell back to provisionTestWorkspace()
which silently leaked a workspaces row because teardownTestWorkspace() is
a no-op (no delete endpoint for acceptance-created workspaces yet). PR CI
never hit this today (no session auth wired), but it was a latent leak
for any future authenticated run.

This PR is now considered cutover-DORMANT-ready, not cutover-LIVE-ready.
Remaining cutover gaps (P1.2 S3 creds, P1.3 STS, P1.4 /api/v1/deploy,
plus ASSETS) require structural decisions and ship as follow-up PRs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Phase 3 Web Worker Build Bot <agent@agent-relay.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er-origin

fix(router): prefer corrected relay web origin
…der (#946)

PR #944 fixed webhook-worker's classification of Nango forward envelopes
(`ingress: "nango-forward"` instead of `nango-sync`), and the queue consumer
now correctly POSTs the raw envelope to `origin.agentrelay.cloud/cloud/api/v1/webhooks/nango`
so cloud-web's `routeForwardEvent` → `handleGitHubForward` can run.

Problem: the router worker catches every `/api/v1/webhooks/nango` request
when `WEBHOOK_ORIGIN=worker` (per ws-option-b flip, 2026-05-20) and forwards
it to webhook-worker — including webhook-worker's own outbound forwards.
The queue consumer re-enqueues the bounced request, queue redelivers,
ad infinitum until `maxRetries: 5` exhausts. Cloud-web's `handleGitHubForward`
is never invoked, `dispatchIntegrationWatchEvent` never fires, and proactive
agents triggered on github events stay inert.

Evidence post-#944 deploy:
- 0 "Nango webhook received" logs on cloud-web-worker
- 0 `integration-watch-dispatch` lines (PR #939 diag)
- All 196+ POSTs to /api/v1/webhooks/nango landed on webhook-worker
- inbound count to webhook-worker rose ~2x post-deploy (loop traffic)

Fix: honour the `x-cloud-webhook-worker-forwarded: webhook-worker` header
that `buildForwardHeaders` already sets in webhook-worker's queue consumer.
When the router sees that header, it skips the webhook-worker redirect and
lets the request fall through to cloud-web — the actual destination.

Tests:
- New `packages/router/test/webhook-routing.test.ts` (4 cases):
  - routes to webhook-worker when WEBHOOK_ORIGIN=worker (existing)
  - bypasses webhook-worker when loop-break header present (new)
  - ignores unrelated header values (don't widen bypass)
  - regression: flag unset still routes to cloud-web
- `npm run typecheck`: clean
- Existing router tests (rate-limit, recorder, redact): 60 tests pass

Co-authored-by: Hubspot Phase B Bot <agent@agent-relay.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores 2794 files accidentally deleted from main; tree returns to known-good b6b9c954 (all tonight's E2E fixes #1205-#1231 intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng-actions (#2158)

* feat(transcripts): wire recorder transcript ingest → workspace VFS → meeting-actions

Closes the last-mile core gap: the desktop recorder's bot-free path transcribes
on the Modal worker but had nowhere to deliver the note (TRANSCRIPTS_INGEST_URL
had no receiver), so customer transcripts never reached meeting-actions.

- New delivery-only route POST /api/v1/webhooks/transcripts: receives the
  transcription-worker's already-transcribed granola note, writes it to the
  customer workspace VFS at /recall/recordings/<id>.json, and dispatches the
  meeting-actions watch event. Transcription stays on the Modal worker.
- Refactored the recall-hookdeck-webhook delivery half into a shared
  `deliverRecallTranscriptNote()` + exported `resolveRecallWorkspaceId()`; the
  existing hookdeck path is behavior-unchanged (still transcribes inline).
- Auth: reuses the existing RecorderTranscribeToken (Bearer) — worker's
  ingestTranscript now sends it; route verifies it. Moved the secret decl to
  infra/secrets.ts so both the worker and web runtime resolve the same value.
- Workspace routing: single-tenant via RECALL_WORKSPACE_ID (wired to the web
  worker + deploy var), else first recall-connected workspace.

FOLLOW-UP (durable multi-tenant, tracked): the recorder now knows its signed-in
workspace (cli:auth login, watchdog-recorder#19) — tag the SDK upload with the
customer rw_ id, propagate it into the worker note, and route the ingest on it,
removing the single-tenant RECALL_WORKSPACE_ID pin.

Human-set values after merge+deploy (Khaliq):
  gh variable set TRANSCRIPTS_INGEST_URL --repo AgentWorkforce/cloud \
    --env production --body https://agentrelay.com/cloud/api/v1/webhooks/transcripts
  gh variable set RECALL_WORKSPACE_ID --repo AgentWorkforce/cloud \
    --env production --body <Ålesund customer rw_ id>

Verified: route unit test 7/7 + existing recall-hookdeck svix tests 11/11 green;
route-coverage OK; transcription-worker tsc clean; packages/web tsc 0 errors;
build:core + build:platform green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transcripts): redact ingest body in router recorder + seed secret + timing-safe bearer

Addresses LoginReview on #2158:

P1 (privacy): the edge traffic recorder clones/records /cloud* request bodies to
R2; the transcripts ingest carries full meeting transcript_text/summary_text and
was not in the router's full-body deny list, so sampled traffic could persist raw
transcripts. Added /(?:cloud/)?api/v1/webhooks/transcripts to
FULL_BODY_REDACTION_PATHS + a redaction test (both /api and /cloud/api forms).

P2 (CI / validate-secret-wiring): RecorderTranscribeToken moved to infra/secrets.ts
and linked into web + transcription workers, but wasn't seeded for long-lived
stages. Added `set_secret RecorderTranscribeToken RECORDER_TRANSCRIBE_TOKEN` to
scripts/set-secrets.sh. validate-secret-wiring.sh now passes.

Non-blocking (LoginReview suggestion): bearer check is now constant-time
(node:crypto timingSafeEqual) instead of string equality.

Verified: router redaction + route + recall-hookdeck svix tests 23/23 green;
validate-secret-wiring OK; route-coverage OK; web + router tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…igin (#2438)

Repoint the apex (non-/cloud, non-/observer) fallback from the legacy AWS
origin origin.agentrelay.net to origin-web.agentrelay.com — the marketing/docs
site now running on Cloudflare Workers from the standalone AgentWorkforce/
agentrelay.com repo. Zero-downtime: the new origin is already live and verified;
revert this one line to roll back to AWS.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the Cloudflare router that fronts agentrelay.com out of cloud's SST app
into this repo (full git history preserved via git-filter-repo), deployed via
plain Wrangler as a sibling to web/. This co-locates the front door with the
web product and frees router changes from cloud's gated full-SST deploy.

- router/ : index.ts + src + tests (61 tests pass, typecheck clean)
- router/wrangler.jsonc : binds the cloud-owned resources cross-repo by
  name/id (CLOUD_WEB_WORKER service, ROUTER_CONFIG + RATE_LIMIT_COUNTERS KV,
  TRAFFIC_RECORDER R2). Starts on router-next.agentrelay.com for parity
  verification; flips to agentrelay.com at the apex cutover.
- .github/workflows/deploy-router.yml : wrangler deploy on router/ changes
- router added as a workspace member

Three cloud-owned values are placeholdered in wrangler.jsonc pending lookup:
the two prod KV namespace ids and the webhook worker *.workers.dev url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@willwashburn, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 26 minutes and 40 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4605564a-4b12-4878-8df3-317eb958dcb7

📥 Commits

Reviewing files that changed from the base of the PR and between d760150 and 4b06f49.

📒 Files selected for processing (3)
  • router/README.md
  • router/index.ts
  • router/wrangler.jsonc
📝 Walkthrough

Walkthrough

This PR adds a new Cloudflare-based router workspace, implements request routing with webhook handling, rate limiting, and sampled traffic recording, wires build and deployment configuration, adds tests for the new behavior, and documents route precedence and deployment paths.

Changes

Router worker rollout

Layer / File(s) Summary
Workspace and deployment scaffold
package.json, router/package.json, router/tsconfig.json, router/wrangler.jsonc, .github/workflows/deploy-router.yml
Adds the router workspace, router package scripts and TypeScript setup, Wrangler bindings and routes, and a GitHub Actions workflow that deploys the worker with Cloudflare credentials.
Rate limiting and recording primitives
router/src/rate-limit.ts, router/src/redact.ts, router/src/recorder.ts, router/test/rate-limit.test.ts, router/test/recorder.test.ts, router/test/redact.test.ts
Implements configurable KV-backed rate limiting, header/body redaction, sampled R2 traffic recording, and tests for bypasses, key extraction, limits, redaction rules, sampling, and stored record contents.
Router request handling
router/index.ts, router/test/webhook-routing.test.ts
Adds the worker entrypoint that rate-limits, optionally records, routes /cloud* and webhook requests to worker bindings or alternate origins, forwards other traffic upstream, rewrites redirect locations, and tests webhook routing decisions.
Route and deployment documentation
router/README.md
Documents route precedence for cloud and observer paths, fallback behavior, RelayFile deployment wiring, and the deployed target mapping.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router
  participant RateLimit
  participant WorkerBinding
  participant Upstream
  participant Recorder

  Client->>Router: HTTP request
  Router->>RateLimit: maybeRateLimit(request)
  alt /cloud or webhook worker route
    Router->>WorkerBinding: fetch(transformed request)
    WorkerBinding-->>Router: response
  else other routed path
    Router->>Upstream: fetch(resolved origin/path)
    Upstream-->>Router: response
  end
  Router->>Recorder: maybeRecord(request, response)
  Router-->>Client: final response
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~100 minutes

Possibly related issues

  • AgentWorkforce/cloud#1967 — This PR implements the Cloudflare router worker, including edge routing, rate limiting, traffic recording, and deployment wiring described in that migration-related issue.

Poem

🐇 I hopped through routes of cloud and file,
With limits set in worker style.
I tucked redactions in each log,
And watched the traffic through the fog.
Now wrangler drums, deploys take flight—
The router hums into the night.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: relocating the front-door router into agentrelay.com as Phase 2 of a planned effort.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description comprehensively covers the changeset, explaining the relocation of the Cloudflare router to this repo, deliverables, configuration requirements, and rollout plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch relocate-router

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Cloudflare Worker router package to manage route precedence, edge-based per-key rate limiting via Cloudflare KV, and a traffic recorder with sensitive data redaction. The feedback highlights two important issues in router/index.ts: first, a potential TypeError caused by stream disturbance when pre-emptively constructing a Request with request.body before verifying environment bindings; second, a missing content-type: application/json header on the 500 error response.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread router/index.ts
Comment on lines +354 to +378
if (await shouldUseWebhookWorker(url.pathname, request, env)) {
const webhookWorkerRequest = buildWebhookWorkerRequest(request, url);
if (env.WEBHOOK_WORKER) {
const workerResponse = await env.WEBHOOK_WORKER.fetch(webhookWorkerRequest);
if (recorderRequestClone && recorderEnv) {
ctx.waitUntil(
maybeRecord(recorderRequestClone, workerResponse.clone(), recorderEnv, ctx),
);
}
return workerResponse;
}

const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim();
if (workerOrigin) {
const originResponse = await globalThis.fetch(
buildWebhookWorkerRequest(request, url, workerOrigin),
);
if (recorderRequestClone && recorderEnv) {
ctx.waitUntil(
maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx),
);
}
return originResponse;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Creating webhookWorkerRequest using request.body before checking if env.WEBHOOK_WORKER is defined can cause a TypeError due to stream disturbance if the code falls back to using env.WEBHOOK_WORKER_ORIGIN. In Cloudflare Workers, a ReadableStream (like request.body) can only be associated with a single Request or Response constructor. If env.WEBHOOK_WORKER is undefined, the stream is already disturbed/locked by the first Request constructor, causing the subsequent buildWebhookWorkerRequest call for workerOrigin to throw an error.

We should defer the creation of the Request objects to their respective conditional branches.

    if (await shouldUseWebhookWorker(url.pathname, request, env)) {
      if (env.WEBHOOK_WORKER) {
        const webhookWorkerRequest = buildWebhookWorkerRequest(request, url);
        const workerResponse = await env.WEBHOOK_WORKER.fetch(webhookWorkerRequest);
        if (recorderRequestClone && recorderEnv) {
          ctx.waitUntil(
            maybeRecord(recorderRequestClone, workerResponse.clone(), recorderEnv, ctx),
          );
        }
        return workerResponse;
      }

      const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim();
      if (workerOrigin) {
        const originResponse = await globalThis.fetch(
          buildWebhookWorkerRequest(request, url, workerOrigin),
        );
        if (recorderRequestClone && recorderEnv) {
          ctx.waitUntil(
            maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx),
          );
        }
        return originResponse;
      }
    }

Comment thread router/index.ts
Comment on lines +435 to +439
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 500 error response returned when globalThis.fetch fails does not specify a content-type header, even though the body is a JSON string. Adding "content-type": "application/json" ensures clients can parse the error response correctly.

    } catch (error) {
      return new Response(JSON.stringify({ error: (error as Error).message }), {
        status: 500,
        headers: { "content-type": "application/json" },
      });
    }

@github-actions

github-actions Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Preview deployed!

Environment URL
Web https://a2a50a3c-agentrelay-web.agent-workforce.workers.dev

This is a Cloudflare Workers preview version of this PR's build.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3d7ac214a3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread router/src/redact.ts
const SENSITIVE_HEADER_NAME = /(token|secret|password|signature)/i;

const FULL_BODY_REDACTION_PATHS = [
/^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add /cloud to full-body redaction rules

Because the recorder passes the original public pathname into redactBody, a secrets request routed through the new front door arrives as /cloud/api/v1/workspaces/.../secrets, which this regex (and the following full-body redaction entries) do not match. Those requests then fall through to field-level JSON redaction and can persist secret/auth/connect-session payload values under non-denylisted keys in TRAFFIC_RECORDER; add the optional /cloud prefix as was done for transcripts.

Useful? React with 👍 / 👎.

Comment thread router/src/redact.ts
Comment on lines +88 to +90
output[key] = JSON_SECRET_KEYS.has(key.toLowerCase())
? redactValue(typeof nested === "string" ? nested : JSON.stringify(nested) ?? "")
: redactJsonValue(nested);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize JSON secret keys before matching

When a recorded JSON body uses conventional snake_case secret fields such as access_token, refresh_token, or client_secret, lowercasing alone leaves the underscores, so none of those names match the deny-list entries like accesstoken and clientsecret. On any route that is not fully redacted, those credentials are written verbatim to the traffic corpus; normalize separators or include the snake_case variants before enabling the recorder.

Useful? React with 👍 / 👎.

Comment thread router/src/rate-limit.ts
// Hookdeck). The webhook worker already has queue-backed
// backpressure, so rate-limiting at the edge would only cause us
// to drop legitimate events. See Codex P1.4 on bundle PR #647.
"/api/v1/webhooks",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exempt /cloud webhooks from rate limiting

maybeRateLimit runs before webhook routing strips the /cloud mount prefix, and the new public router receives webhook URLs as /cloud/api/v1/webhooks/.... This bypass only covers the unmounted path, so provider bursts through agentrelay.com are bucketed by IP and start returning 429 after the default 60/min, dropping legitimate webhook events; include the /cloud-mounted prefix too.

Useful? React with 👍 / 👎.

Comment thread router/wrangler.jsonc Outdated
Comment on lines +24 to +25
{ "binding": "ROUTER_CONFIG", "id": "REPLACE_ME__router_config_kv_id" },
{ "binding": "RATE_LIMIT_COUNTERS", "id": "REPLACE_ME__rate_limit_counters_kv_id" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace placeholder KV IDs before auto-deploy

The new workflow deploys this worker on pushes to main that touch router/**, but the config still binds ROUTER_CONFIG and RATE_LIMIT_COUNTERS to literal placeholder IDs. Merging in this state makes the first wrangler deploy target non-existent KV namespaces, so router-next.agentrelay.com will not publish until the real production IDs are committed or injected at deploy time.

Useful? React with 👍 / 👎.

willwashburn and others added 2 commits June 23, 2026 10:37
Use a WEBHOOK_WORKER service binding (script name `webhook-worker`) instead of
the WEBHOOK_WORKER_ORIGIN *.workers.dev URL var. The router code already prefers
the binding, and binding by stable script name avoids pinning an account-specific
URL — one fewer cross-repo value that could drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
router/README.md (1)

17-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Specify language identifier for the fenced code block.

Add a language identifier to the code fence to satisfy the markdown linter and improve editor syntax highlighting.

Suggested fix
-```
+```text
                                      ┌────────────────────────────────────┐
                                      │       `@agentworkforce/cloud`        │
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/README.md` at line 17, The fenced code block in router/README.md is
missing a language identifier on its opening fence, which violates the markdown
linter rules and prevents syntax highlighting. Add a language identifier (such
as `text`) immediately after the opening triple backticks (```) at the start of
the code block that contains the ASCII diagram showing the `@agentworkforce/cloud`
architecture.

Source: Linters/SAST tools

router/test/redact.test.ts (1)

22-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression tests for /cloud deny-list parity and malformed-JSON fail-closed behavior.

Please add cases for /cloud/api/... on the other full-body redaction routes (not just transcripts), plus a JSON content-type payload that is malformed and should still be redacted.

Also applies to: 42-78

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/test/redact.test.ts` around lines 22 - 40, Add additional regression
test cases to cover `/cloud` deny-list parity and malformed JSON behavior.
First, extend or create test cases similar to the transcript redaction test to
verify that other full-body redaction routes (such as the
`/api/v1/workspaces/ws_123/secrets` path tested in the "fully redacts bodies for
deny-listed paths" test) also properly redact when accessed with the `/cloud`
prefix (e.g., `/cloud/api/v1/workspaces/ws_123/secrets`). Second, add a new test
case that verifies the redactBody function handles malformed JSON payloads with
`application/json` content-type by still redacting them completely, ensuring
fail-closed behavior for invalid JSON input.
.github/workflows/deploy-router.yml (1)

25-29: 🔒 Security & Privacy | 🔵 Trivial

Consider pinning GitHub Actions to commit SHAs for stricter supply-chain control.

While using major version tags (@v4) is GitHub's recommended approach for official actions and ensures you receive security patches, pinning to specific commit SHAs provides explicit control and prevents unexpected behavior changes. This is optional but aligns with defense-in-depth practices if your organization requires stricter CI/CD hardening.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-router.yml around lines 25 - 29, The GitHub Actions
used in the workflow file are pinned to major version tags (`@v4`) rather than
specific commit SHAs. Replace the version tags in the `actions/checkout@v4` and
`actions/setup-node@v4` actions with their corresponding commit SHAs (for
example, `actions/checkout@<commit-sha>` and `actions/setup-node@<commit-sha>`)
to provide stricter supply-chain control and prevent unexpected behavior changes
from action updates.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/deploy-router.yml:
- Around line 33-41: The workflow is missing a validation step to prevent
deploying with unresolved configuration values. Add a new step between "Install
dependencies" and "Deploy router to Cloudflare" that checks the
router/wrangler.jsonc file for any remaining REPLACE_ME__ sentinel values using
a grep or similar validation command, and fails the job if any are found. This
guard should execute before the wrangler deploy command runs to catch
configuration issues early.
- Around line 24-25: The actions/checkout@v4 step in the deploy-router workflow
is missing the persist-credentials configuration which leaves a writable
authentication token in the workspace after checkout. Add the
persist-credentials input parameter set to false on the checkout action to
prevent this security risk, as the workflow performs no authenticated Git
operations after the initial checkout step.

In `@router/index.ts`:
- Around line 290-295: The code constructs a new URL object from the
workerOrigin config value without validation, which will throw an uncaught
exception if the environment variable is malformed. Wrap the URL construction
logic in a try-catch block to catch any exceptions thrown by new
URL(workerOrigin), and return a controlled error response instead of allowing
the exception to escape. Apply this validation fix to both code blocks where
this pattern occurs (the first at the location shown and the second at lines
366-370 as noted in the comment).

In `@router/src/redact.ts`:
- Around line 11-23: The FULL_BODY_REDACTION_PATHS array in redact.ts currently
only has one pattern that handles the optional /cloud prefix for sensitive
routes. Update all remaining regex patterns in this array to also match their
/cloud-prefixed equivalents by adding the optional (?:cloud\/)? prefix pattern
at the beginning of each route, similar to how it's already done for the
/api/v1/webhooks/transcripts pattern. This applies to all patterns that start
with /api/ such as those for workspaces/secrets, /api/auth, /api/v1/auth,
/api/v1/cli/auth, /api/v1/cli/login, /api/v1/integrations/nango/connect-link,
and workspaces integrations connect-session.
- Around line 123-127: The catch block in the JSON redaction logic currently
returns the original unredacted body when JSON parsing fails, which can leak
secrets into recorder storage. Instead of returning the raw body in the catch
block following the JSON.parse and redactJsonValue calls, return a safe default
value such as an empty string or a JSON object that indicates redaction failure,
ensuring that unparseable content does not expose sensitive information.

In `@router/wrangler.jsonc`:
- Around line 36-39: The WEBHOOK_WORKER_ORIGIN configuration value in
wrangler.jsonc contains a sentinel placeholder
"REPLACE_ME__webhook_worker_workers_dev_url" instead of the actual webhook
worker URL. Replace this placeholder value with the actual generated
*.workers.dev URL of your webhook worker by looking it up via the `wrangler
deployments list --name <webhook worker script>` command or checking the
Cloudflare dashboard, then update the WEBHOOK_WORKER_ORIGIN value with the
correct URL before merging.

---

Nitpick comments:
In @.github/workflows/deploy-router.yml:
- Around line 25-29: The GitHub Actions used in the workflow file are pinned to
major version tags (`@v4`) rather than specific commit SHAs. Replace the version
tags in the `actions/checkout@v4` and `actions/setup-node@v4` actions with their
corresponding commit SHAs (for example, `actions/checkout@<commit-sha>` and
`actions/setup-node@<commit-sha>`) to provide stricter supply-chain control and
prevent unexpected behavior changes from action updates.

In `@router/README.md`:
- Line 17: The fenced code block in router/README.md is missing a language
identifier on its opening fence, which violates the markdown linter rules and
prevents syntax highlighting. Add a language identifier (such as `text`)
immediately after the opening triple backticks (```) at the start of the code
block that contains the ASCII diagram showing the `@agentworkforce/cloud`
architecture.

In `@router/test/redact.test.ts`:
- Around line 22-40: Add additional regression test cases to cover `/cloud`
deny-list parity and malformed JSON behavior. First, extend or create test cases
similar to the transcript redaction test to verify that other full-body
redaction routes (such as the `/api/v1/workspaces/ws_123/secrets` path tested in
the "fully redacts bodies for deny-listed paths" test) also properly redact when
accessed with the `/cloud` prefix (e.g.,
`/cloud/api/v1/workspaces/ws_123/secrets`). Second, add a new test case that
verifies the redactBody function handles malformed JSON payloads with
`application/json` content-type by still redacting them completely, ensuring
fail-closed behavior for invalid JSON input.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 94d73fe8-3f84-4767-a964-f69f7b386e82

📥 Commits

Reviewing files that changed from the base of the PR and between c7b1ec2 and d760150.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (14)
  • .github/workflows/deploy-router.yml
  • package.json
  • router/README.md
  • router/index.ts
  • router/package.json
  • router/src/rate-limit.ts
  • router/src/recorder.ts
  • router/src/redact.ts
  • router/test/rate-limit.test.ts
  • router/test/recorder.test.ts
  • router/test/redact.test.ts
  • router/test/webhook-routing.test.ts
  • router/tsconfig.json
  • router/wrangler.jsonc

Comment on lines +24 to +25
- name: Checkout code
uses: actions/checkout@v4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -i "deploy-router"

Repository: AgentWorkforce/agentrelay.com

Length of output: 203


🏁 Script executed:

cat -n .github/workflows/deploy-router.yml | head -40

Repository: AgentWorkforce/agentrelay.com

Length of output: 1281


🌐 Web query:

actions/checkout v4 persist-credentials security best practice GitHub Actions

💡 Result:

Setting persist-credentials: false in the actions/checkout action is considered a security best practice for most GitHub Actions workflows [1][2][3]. By default (persist-credentials: true), the action configures the Git local configuration with the provided token or SSH key [4][5][6]. This allows subsequent steps in the same job to perform authenticated Git operations automatically, but it also creates a security risk: any process or action executed later in the job can access these credentials from the.git/config file [7][2]. If a workflow step is compromised, or if the working directory is inadvertently uploaded as an artifact, the credentials may be exposed to unauthorized parties [2][3]. Key security considerations: 1. Recommended Practice: Set persist-credentials: false unless your workflow explicitly requires authenticated Git operations (e.g., pushing commits back to the repository) after the checkout step [1][2][3]. 2. Risk Reduction: Disabling credential persistence ensures that credentials are not stored on disk in the runner's working directory, significantly reducing the potential blast radius if a workflow is compromised [1][2]. 3. Manual Credential Management: If you disable persistence but still need to perform authenticated Git operations, you must manually configure the required authentication (e.g., by using an environment variable or explicit configuration) in the steps that require it [2]. 4. Context Matters: While GITHUB_TOKEN has default permissions and is handled by the runner environment, persist-credentials specifically controls whether that token is written to the Git configuration on disk [4][7]. For high-value workflows, such as those performing releases or deployments, it is especially important to follow the principle of least privilege, limit the scope of tokens, and consider using more secure alternatives like OIDC (OpenID Connect) for authentication with cloud providers rather than relying on long-lived or persisted secrets [8][3].

Citations:


Disable checkout credential persistence.

Set persist-credentials: false on the checkout action to avoid leaving a writable token in the workspace for subsequent steps. This workflow performs no authenticated Git operations after checkout, so credential persistence is unnecessary and increases the attack surface.

Suggested change
      - name: Checkout code
        uses: actions/checkout@v4
+       with:
+         persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 24-25: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 25-25: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-router.yml around lines 24 - 25, The
actions/checkout@v4 step in the deploy-router workflow is missing the
persist-credentials configuration which leaves a writable authentication token
in the workspace after checkout. Add the persist-credentials input parameter set
to false on the checkout action to prevent this security risk, as the workflow
performs no authenticated Git operations after the initial checkout step.

Source: Linters/SAST tools

Comment on lines +33 to +41
- name: Install dependencies
run: npm ci

- name: Deploy router to Cloudflare
working-directory: router
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: ../node_modules/.bin/wrangler deploy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a pre-deploy guard for unresolved config sentinels.

A fast validation step should fail the job if REPLACE_ME__ values remain in router/wrangler.jsonc, preventing bad production deploys.

Suggested change
       - name: Install dependencies
         run: npm ci

+      - name: Validate router wrangler config
+        run: |
+          if grep -q "REPLACE_ME__" router/wrangler.jsonc; then
+            echo "Unresolved placeholder found in router/wrangler.jsonc"
+            exit 1
+          fi
+
       - name: Deploy router to Cloudflare
         working-directory: router
         env:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-router.yml around lines 33 - 41, The workflow is
missing a validation step to prevent deploying with unresolved configuration
values. Add a new step between "Install dependencies" and "Deploy router to
Cloudflare" that checks the router/wrangler.jsonc file for any remaining
REPLACE_ME__ sentinel values using a grep or similar validation command, and
fails the job if any are found. This guard should execute before the wrangler
deploy command runs to catch configuration issues early.

Comment thread router/index.ts
Comment on lines +290 to +295
if (workerOrigin) {
const originUrl = new URL(workerOrigin);
targetUrl.protocol = originUrl.protocol;
targetUrl.hostname = originUrl.hostname;
targetUrl.port = originUrl.port;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate WEBHOOK_WORKER_ORIGIN before request construction.

Line 291 constructs new URL(workerOrigin) from config without validation. If this env value is malformed, the throw escapes this branch and webhook requests fail with an uncaught runtime error instead of a controlled response.

Suggested fix
   const workerOrigin = env.WEBHOOK_WORKER_ORIGIN?.trim();
   if (workerOrigin) {
-    const originResponse = await globalThis.fetch(
-      buildWebhookWorkerRequest(request, url, workerOrigin),
-    );
+    let forwardedRequest: Request;
+    try {
+      forwardedRequest = buildWebhookWorkerRequest(request, url, workerOrigin);
+    } catch {
+      return new Response(
+        JSON.stringify({ error: "invalid webhook worker origin configuration" }),
+        {
+          status: 503,
+          headers: { "content-type": "application/json" },
+        },
+      );
+    }
+    const originResponse = await globalThis.fetch(forwardedRequest);
     if (recorderRequestClone && recorderEnv) {
       ctx.waitUntil(
         maybeRecord(recorderRequestClone, originResponse.clone(), recorderEnv, ctx),
       );
     }

Also applies to: 366-370

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/index.ts` around lines 290 - 295, The code constructs a new URL object
from the workerOrigin config value without validation, which will throw an
uncaught exception if the environment variable is malformed. Wrap the URL
construction logic in a try-catch block to catch any exceptions thrown by new
URL(workerOrigin), and return a controlled error response instead of allowing
the exception to escape. Apply this validation fix to both code blocks where
this pattern occurs (the first at the location shown and the second at lines
366-370 as noted in the comment).

Comment thread router/src/redact.ts
Comment on lines +11 to +23
const FULL_BODY_REDACTION_PATHS = [
/^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
/^\/api\/auth(?:\/.*)?$/,
/^\/api\/v1\/auth(?:\/.*)?$/,
/^\/api\/v1\/cli\/auth(?:\/.*)?$/,
/^\/api\/v1\/cli\/login(?:\/.*)?$/,
/^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
/^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
// Recorder transcript ingest carries full meeting transcript_text/summary_text;
// never persist its body to the traffic corpus. Match with or without the
// /cloud app prefix.
/^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/,
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Extend full-body redaction rules to /cloud-prefixed sensitive routes.

Most deny-listed patterns only match /api/.... Router-ingressed equivalents under /cloud/api/... can bypass full-body redaction and persist secrets.

Suggested change
 const FULL_BODY_REDACTION_PATHS = [
-  /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
-  /^\/api\/auth(?:\/.*)?$/,
-  /^\/api\/v1\/auth(?:\/.*)?$/,
-  /^\/api\/v1\/cli\/auth(?:\/.*)?$/,
-  /^\/api\/v1\/cli\/login(?:\/.*)?$/,
-  /^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
-  /^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/auth(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/auth(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/cli\/auth(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/cli\/login(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
+  /^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const FULL_BODY_REDACTION_PATHS = [
/^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
/^\/api\/auth(?:\/.*)?$/,
/^\/api\/v1\/auth(?:\/.*)?$/,
/^\/api\/v1\/cli\/auth(?:\/.*)?$/,
/^\/api\/v1\/cli\/login(?:\/.*)?$/,
/^\/api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
/^\/api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
// Recorder transcript ingest carries full meeting transcript_text/summary_text;
// never persist its body to the traffic corpus. Match with or without the
// /cloud app prefix.
/^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/,
];
const FULL_BODY_REDACTION_PATHS = [
/^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/secrets(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/auth(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/v1\/auth(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/v1\/cli\/auth(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/v1\/cli\/login(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/v1\/integrations\/nango\/connect-link(?:\/.*)?$/,
/^\/(?:cloud\/)?api\/v1\/workspaces\/[a-zA-Z0-9_-]+\/integrations\/connect-session(?:\/.*)?$/,
// Recorder transcript ingest carries full meeting transcript_text/summary_text;
// never persist its body to the traffic corpus. Match with or without the
// /cloud app prefix.
/^\/(?:cloud\/)?api\/v1\/webhooks\/transcripts(?:\/.*)?$/,
];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/src/redact.ts` around lines 11 - 23, The FULL_BODY_REDACTION_PATHS
array in redact.ts currently only has one pattern that handles the optional
/cloud prefix for sensitive routes. Update all remaining regex patterns in this
array to also match their /cloud-prefixed equivalents by adding the optional
(?:cloud\/)? prefix pattern at the beginning of each route, similar to how it's
already done for the /api/v1/webhooks/transcripts pattern. This applies to all
patterns that start with /api/ such as those for workspaces/secrets, /api/auth,
/api/v1/auth, /api/v1/cli/auth, /api/v1/cli/login,
/api/v1/integrations/nango/connect-link, and workspaces integrations
connect-session.

Comment thread router/src/redact.ts
Comment on lines +123 to +127
try {
return JSON.stringify(redactJsonValue(JSON.parse(body)));
} catch {
return body;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed on JSON parse errors in redaction.

For JSON content types, parse failures currently return the original body unchanged. That can leak secrets into recorder storage.

Suggested change
   try {
     return JSON.stringify(redactJsonValue(JSON.parse(body)));
   } catch {
-    return body;
+    return redactValue(body);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@router/src/redact.ts` around lines 123 - 127, The catch block in the JSON
redaction logic currently returns the original unredacted body when JSON parsing
fails, which can leak secrets into recorder storage. Instead of returning the
raw body in the catch block following the JSON.parse and redactJsonValue calls,
return a safe default value such as an empty string or a JSON object that
indicates redaction failure, ensuring that unparseable content does not expose
sensitive information.

Comment thread router/wrangler.jsonc Outdated
Address review feedback:
- Build the forwarded webhook Request inside each branch so request.body
  (a single-use ReadableStream) isn't disturbed before the WEBHOOK_WORKER vs
  WEBHOOK_WORKER_ORIGIN choice — the fallback path could otherwise throw.
- Set content-type: application/json on the 500 error response (JSON body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willwashburn

Copy link
Copy Markdown
Member Author

Thanks for the reviews (Gemini / Codex / CodeRabbit). Triaged below.

✅ Fixed in this PR

  • Webhook-request stream disturbance (Gemini HIGH, CodeRabbit index.ts): the forwarded Request is now built inside each branch, so request.body (a single-use ReadableStream) is never constructed before the WEBHOOK_WORKER vs WEBHOOK_WORKER_ORIGIN choice — the fallback path can no longer throw.
  • Missing content-type on the 500 (Gemini MED): the catch-all error now sets content-type: application/json.

✅ Already resolved (reviews ran on the pre-fix commit 3d7ac21)

  • Placeholder KV ids / WEBHOOK_WORKER_ORIGIN placeholder (Codex P1, CodeRabbit): the KV ids are now pinned to the real prod namespaces, and the webhook target was switched from the pinned *.workers.dev URL to a WEBHOOK_WORKER service binding (by stable script name) — so there's no placeholder left and one fewer cross-repo value to drift.

⏭️ Deferred — pre-existing cloud-router behavior, not a regression from the move

These are carried over verbatim from cloud/packages/router and exist in the live prod router today, so the relocation is behavior-neutral. They deserve a dedicated (security) follow-up in both repos rather than a behavior change mid-relocation:

  • redact: extend full-body redaction to /cloud-prefixed sensitive routes (Codex/CodeRabbit P1)
  • redact: normalize JSON secret keys before matching + fail-closed on JSON parse errors
  • rate-limit: exempt /cloud webhook ingress from per-key rate limiting (Codex P2)

I'll file these as a follow-up so they get fixed in cloud's copy too.

Verification: 61 router tests pass, typecheck clean, wrangler deploy --dry-run bundles with all bindings resolved.

willwashburn and others added 2 commits June 23, 2026 11:09
The router and web app are no longer in cloud/relay — relabel the diagram
boxes: @agentworkforce/cloud /packages/router -> @agentworkforce/agentrelay.com
/router, and the marketing web source -> @agentworkforce/agentrelay.com /web.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willwashburn
willwashburn merged commit f3a74fa into main Jun 23, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants