Skip to content

Add Cloudflare Durable Object subrouter#50

Merged
lawrencecchen merged 1 commit into
mainfrom
feat-cloudflare-do
Jul 3, 2026
Merged

Add Cloudflare Durable Object subrouter#50
lawrencecchen merged 1 commit into
mainfrom
feat-cloudflare-do

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an isolated cloudflare/ Bun workspace with an Effect-based subrouter core and Cloudflare Durable Object worker
  • port Go-compatible routing/admin/session/transcript/proxy endpoints into the worker
  • add Cloudflare CI/CD workflow with verify, staging deploy on main, and manual production deploy

Deploy evidence

  • staging deployed: regatta-subrouter-do-staging version d7812871-226d-46e2-a55f-566b2fac2bdf
  • production deployed: regatta-subrouter-do-production version 983335bb-bfe0-4a7b-96c4-123313d82e6c
  • https://subrouter.cmux.dev/healthz returns {"ok":true,"service":"subrouter-do"}

Verification

  • cd cloudflare && bun install --frozen-lockfile
  • cd cloudflare && bun run typecheck
  • cd cloudflare && bun run test
  • cd cloudflare/packages/worker && bunx wrangler deploy --env staging --dry-run --outdir dist-dry-run
  • go build ./...
  • go vet ./...
  • go test ./... -count=1
  • actionlint .github/workflows/cloudflare-do.yml

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Adds a Cloudflare Durable Object-based subrouter with sticky session routing and Go-compatible admin/proxy endpoints. Includes a new cloudflare/ workspace and CI/CD for verify, staging on main, and manual production deploys.

  • New Features

    • Cloudflare Durable Object subrouter with per-org state and sticky routing.
    • Go-compatible /_subrouter/* admin, session, transcript, and proxy endpoints; proxies Codex/OpenAI/Claude.
    • New cloudflare/ workspace with @subrouter/core (Effect-based routing) and @subrouter/cloudflare-worker; CI/CD for verify + staged/prod deploys.
  • Migration

    • Add Cloudflare secrets per env: ADMIN_TOKEN and PROXY_TOKEN.
    • Deploy via the workflow or @subrouter/cloudflare-worker scripts (deploy:staging / deploy:production).
    • Update callers to the new subrouter URLs; verify with /healthz.

Written for commit 324c515. Summary will update on new commits.

Review in cubic

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​bun@​1.3.141001004989100
Addednpm/​@​types/​node@​22.20.01001008196100
Addednpm/​effect@​3.21.4821009197100
Addednpm/​typescript@​5.9.3100100909690
Addednpm/​wrangler@​4.107.0981009296100
Addednpm/​@​cloudflare/​workers-types@​4.20260702.110010010099100

View full report

@socket-security

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm effect is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: cloudflare/packages/core/package.jsonnpm/effect@3.21.4

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/effect@3.21.4. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@lawrencecchen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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 reviews.

How do review limits work?

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

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8993431e-c590-4af7-be14-ed9fb791c328

📥 Commits

Reviewing files that changed from the base of the PR and between c52436b and 324c515.

⛔ Files ignored due to path filters (1)
  • cloudflare/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (18)
  • .github/workflows/cloudflare-do.yml
  • cloudflare/.gitignore
  • cloudflare/package.json
  • cloudflare/packages/core/package.json
  • cloudflare/packages/core/src/index.ts
  • cloudflare/packages/core/src/service.ts
  • cloudflare/packages/core/test/service.test.ts
  • cloudflare/packages/core/test/sticky.test.ts
  • cloudflare/packages/core/tsconfig.json
  • cloudflare/packages/worker/README.md
  • cloudflare/packages/worker/package.json
  • cloudflare/packages/worker/src/contract.ts
  • cloudflare/packages/worker/src/index.ts
  • cloudflare/packages/worker/test/contract.test.ts
  • cloudflare/packages/worker/test/worker-transcripts.test.ts
  • cloudflare/packages/worker/tsconfig.json
  • cloudflare/packages/worker/wrangler.json
  • cloudflare/tsconfig.base.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-cloudflare-do

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.

@cubic-dev-ai cubic-dev-ai 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.

12 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cloudflare/packages/worker/src/contract.ts">

<violation number="1" location="cloudflare/packages/worker/src/contract.ts:247">
P3: `findJsonString` recursively walks every nested value of the request body with no depth cap. For JSON payloads with extreme nesting this could cause a stack overflow. Consider adding a max depth parameter (e.g. 10) to bound recursion.</violation>

<violation number="2" location="cloudflare/packages/worker/src/contract.ts:318">
P2: `accountStatus` assigns both `auth_checked` and `auth_valid` to `account.hasCredentials`, which conflates "credentials exist" with "credentials are valid". This can mislead admin/status consumers into believing the account's auth was actively verified when only the presence of stored credential fields was checked. Consider renaming the field to `has_credentials` or performing an actual validation check before reporting `auth_valid: true`.</violation>
</file>

<file name="cloudflare/packages/core/package.json">

<violation number="1" location="cloudflare/packages/core/package.json:3">
P2: Missing `"private": true` on this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runs `npm publish` or `bun publish` from inside the package directory, it could accidentally publish to the npm registry. Add `"private": true` to prevent accidental publication.</violation>
</file>

<file name="cloudflare/packages/core/src/index.ts">

<violation number="1" location="cloudflare/packages/core/src/index.ts:54">
P3: `quotaKeyForModel` uses `String.includes()` to match pool keywords, so model names containing "spark", "opus", or "sonnet" as substrings would silently route to the wrong quota pool. Consider word-boundary matching or an explicit allowlist if model names evolve beyond current conventions.</violation>

<violation number="2" location="cloudflare/packages/core/src/index.ts:117">
P2: The round-robin cursor is shared across all orgs and quota keys, so distribution for one tenant or quota pool is skewed by requests from other tenants/pools. In a multi-tenant reference implementation this can produce confusing test behavior. Consider maintaining a Map<(orgId, resolvedQuotaKey) => number> for per-org-per-pool cursors.</violation>

<violation number="3" location="cloudflare/packages/core/src/index.ts:131">
P2: Pinned-account requests can be ignored for existing sticky sessions. Because sticky lookup runs before `preferAccountId`, a request that tries to force a specific account may still route to the old sticky account. Reordering this flow so explicit pins are evaluated before sticky lookup would align behavior with the documented `X-Subrouter-Account-ID` force semantics.</violation>
</file>

<file name="cloudflare/packages/worker/test/worker-transcripts.test.ts">

<violation number="1" location="cloudflare/packages/worker/test/worker-transcripts.test.ts:386">
P2: wrangler dev stdout/stderr pipes are created but never consumed. If wrangler writes enough output (e.g., startup logs, warnings) to fill the pipe buffer (~64KB on Linux), the child process will block on write and the test will hang. With `--log-level error` this is unlikely but not guaranteed over a 60s test window. Either consume the streams (pipe to parent process or drain to /dev/null) or use `"inherit"` / `"ignore"` since the output is not asserted.</violation>
</file>

<file name="cloudflare/packages/worker/src/index.ts">

<violation number="1">
P3: `listTranscriptSummaries` and `transcriptDashboardData` duplicate the full transcript-summary-building loop — iterating rows, constructing compound keys with `baseSessionId`, building `MutableTranscriptSummary` via `applyTranscriptEvent`, and sorting with the same comparator. Extraction into a shared `buildTranscriptSummaries(sql, orgId)` helper would eliminate the duplication and keep the two endpoints consistent.</violation>

<violation number="2">
P2: Unmatched `/admin/*` sub-routes fall through to the proxy handler instead of returning 404. For example, a `GET` to `/admin/accounts/foo/totp` passes admin auth, matches no sub-handler (TOTP only handles POST), then bypasses the `/_subrouter/` catch-all and reaches the proxy handler. If `PROXY_TOKEN` is unset the proxy auth reuses `ADMIN_TOKEN`, so the request gets proxied upstream with an unrecognized path. This is a routing gap: admin sub-routes that don't match a defined method/path should produce 404, not fall through to the proxy pipeline.</violation>

<violation number="3">
P1: `parseRouteInput` silently falls back to demo defaults when the JSON body is malformed, empty, or an array. A POST to `/route` with a malformed body or `application/json` but `[]` gets routed to `demo-org` / `default` session with zero error feedback to the caller. The caller gets a successful HTTP 200 route response when the API contract was violated — this silently breaks the routing contract and makes integration bugs very hard to debug. Recommend returning `json({ error: \"Expected a JSON object body\" }, { status: 400 })` when the body is missing, invalid, or an array.</violation>

<violation number="4">
P2: Token auth currently treats the `Bearer` scheme as case-sensitive, so requests using a lowercase (but valid) scheme like `authorization: bearer ...` are rejected as unauthorized. Using case-insensitive parsing for the scheme would make auth handling standards-compliant and avoid avoidable client compatibility failures.</violation>

<violation number="5">
P1: WebSocket clients can currently call routing and usage operations without presenting `PROXY_TOKEN`/`ADMIN_TOKEN`, because the `/ws` branch forwards upgrades directly to the Durable Object before any auth gate. That exposes state-changing subrouter actions to unauthenticated callers. It would be safer to apply the same proxy-token check used for `/v1/*` proxy traffic before accepting the websocket.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

@@ -0,0 +1,3022 @@
import { DurableObject } from "cloudflare:workers"

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: parseRouteInput silently falls back to demo defaults when the JSON body is malformed, empty, or an array. A POST to /route with a malformed body or application/json but [] gets routed to demo-org / default session with zero error feedback to the caller. The caller gets a successful HTTP 200 route response when the API contract was violated — this silently breaks the routing contract and makes integration bugs very hard to debug. Recommend returning json({ error: \"Expected a JSON object body\" }, { status: 400 }) when the body is missing, invalid, or an array.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/index.ts:

<comment>`parseRouteInput` silently falls back to demo defaults when the JSON body is malformed, empty, or an array. A POST to `/route` with a malformed body or `application/json` but `[]` gets routed to `demo-org` / `default` session with zero error feedback to the caller. The caller gets a successful HTTP 200 route response when the API contract was violated — this silently breaks the routing contract and makes integration bugs very hard to debug. Recommend returning `json({ error: \"Expected a JSON object body\" }, { status: 400 })` when the body is missing, invalid, or an array.</comment>

@@ -0,0 +1,3022 @@
import { DurableObject } from "cloudflare:workers"

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: WebSocket clients can currently call routing and usage operations without presenting PROXY_TOKEN/ADMIN_TOKEN, because the /ws branch forwards upgrades directly to the Durable Object before any auth gate. That exposes state-changing subrouter actions to unauthenticated callers. It would be safer to apply the same proxy-token check used for /v1/* proxy traffic before accepting the websocket.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/index.ts:

<comment>WebSocket clients can currently call routing and usage operations without presenting `PROXY_TOKEN`/`ADMIN_TOKEN`, because the `/ws` branch forwards upgrades directly to the Durable Object before any auth gate. That exposes state-changing subrouter actions to unauthenticated callers. It would be safer to apply the same proxy-token check used for `/v1/*` proxy traffic before accepting the websocket.</comment>

export const accountStatus = (account: StoredAccountContract) => ({
...safeGoAccount(account),
auth_checked: account.hasCredentials,
auth_valid: account.hasCredentials,

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: accountStatus assigns both auth_checked and auth_valid to account.hasCredentials, which conflates "credentials exist" with "credentials are valid". This can mislead admin/status consumers into believing the account's auth was actively verified when only the presence of stored credential fields was checked. Consider renaming the field to has_credentials or performing an actual validation check before reporting auth_valid: true.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/contract.ts, line 318:

<comment>`accountStatus` assigns both `auth_checked` and `auth_valid` to `account.hasCredentials`, which conflates "credentials exist" with "credentials are valid". This can mislead admin/status consumers into believing the account's auth was actively verified when only the presence of stored credential fields was checked. Consider renaming the field to `has_credentials` or performing an actual validation check before reporting `auth_valid: true`.</comment>

<file context>
@@ -0,0 +1,792 @@
+export const accountStatus = (account: StoredAccountContract) => ({
+  ...safeGoAccount(account),
+  auth_checked: account.hasCredentials,
+  auth_valid: account.hasCredentials,
+})
+
</file context>

@@ -0,0 +1,16 @@
{
"name": "@subrouter/core",
"version": "0.0.0",

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: Missing "private": true on this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runs npm publish or bun publish from inside the package directory, it could accidentally publish to the npm registry. Add "private": true to prevent accidental publication.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/package.json, line 3:

<comment>Missing `"private": true` on this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runs `npm publish` or `bun publish` from inside the package directory, it could accidentally publish to the npm registry. Add `"private": true` to prevent accidental publication.</comment>

<file context>
@@ -0,0 +1,16 @@
+{
+  "name": "@subrouter/core",
+  "version": "0.0.0",
+  "type": "module",
+  "main": "./src/index.ts",
</file context>

export const makeInMemoryAccountStoreLayer = (initial: ReadonlyArray<Account>) => {
const accounts = [...initial]
const sticky = makeInMemoryStickyStore()
let cursor = 0

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: The round-robin cursor is shared across all orgs and quota keys, so distribution for one tenant or quota pool is skewed by requests from other tenants/pools. In a multi-tenant reference implementation this can produce confusing test behavior. Consider maintaining a Map<(orgId, resolvedQuotaKey) => number> for per-org-per-pool cursors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 117:

<comment>The round-robin cursor is shared across all orgs and quota keys, so distribution for one tenant or quota pool is skewed by requests from other tenants/pools. In a multi-tenant reference implementation this can produce confusing test behavior. Consider maintaining a Map<(orgId, resolvedQuotaKey) => number> for per-org-per-pool cursors.</comment>

<file context>
@@ -0,0 +1,163 @@
+export const makeInMemoryAccountStoreLayer = (initial: ReadonlyArray<Account>) => {
+  const accounts = [...initial]
+  const sticky = makeInMemoryStickyStore()
+  let cursor = 0
+
+  return Layer.succeed(AccountStoreTag, {
</file context>

@@ -0,0 +1,3022 @@
import { DurableObject } from "cloudflare:workers"

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: Token auth currently treats the Bearer scheme as case-sensitive, so requests using a lowercase (but valid) scheme like authorization: bearer ... are rejected as unauthorized. Using case-insensitive parsing for the scheme would make auth handling standards-compliant and avoid avoidable client compatibility failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/index.ts:

<comment>Token auth currently treats the `Bearer` scheme as case-sensitive, so requests using a lowercase (but valid) scheme like `authorization: bearer ...` are rejected as unauthorized. Using case-insensitive parsing for the scheme would make auth handling standards-compliant and avoid avoidable client compatibility failures.</comment>

accountHasQuotaForModel(account, resolvedQuotaKey)

// 1. sticky table first
const stickyId = sticky.get(orgId, sessionId, resolvedQuotaKey)

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: Pinned-account requests can be ignored for existing sticky sessions. Because sticky lookup runs before preferAccountId, a request that tries to force a specific account may still route to the old sticky account. Reordering this flow so explicit pins are evaluated before sticky lookup would align behavior with the documented X-Subrouter-Account-ID force semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 131:

<comment>Pinned-account requests can be ignored for existing sticky sessions. Because sticky lookup runs before `preferAccountId`, a request that tries to force a specific account may still route to the old sticky account. Reordering this flow so explicit pins are evaluated before sticky lookup would align behavior with the documented `X-Subrouter-Account-ID` force semantics.</comment>

<file context>
@@ -0,0 +1,163 @@
+        accountHasQuotaForModel(account, resolvedQuotaKey)
+
+      // 1. sticky table first
+      const stickyId = sticky.get(orgId, sessionId, resolvedQuotaKey)
+      if (stickyId) {
+        const found = accounts.find((a) => a.id === stickyId && isEligible(a))
</file context>

@@ -0,0 +1,792 @@
import type { Account } from "@subrouter/core"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: findJsonString recursively walks every nested value of the request body with no depth cap. For JSON payloads with extreme nesting this could cause a stack overflow. Consider adding a max depth parameter (e.g. 10) to bound recursion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/contract.ts, line 247:

<comment>`findJsonString` recursively walks every nested value of the request body with no depth cap. For JSON payloads with extreme nesting this could cause a stack overflow. Consider adding a max depth parameter (e.g. 10) to bound recursion.</comment>

<file context>
@@ -0,0 +1,792 @@
+  }
+}
+
+const findJsonString = (
+  value: unknown,
+  keys: ReadonlyArray<string>
</file context>

const normalized = model?.trim().toLowerCase()
if (!normalized) return "default"
for (const pool of MODEL_QUOTA_POOLS) {
if (normalized.includes(pool)) return pool

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: quotaKeyForModel uses String.includes() to match pool keywords, so model names containing "spark", "opus", or "sonnet" as substrings would silently route to the wrong quota pool. Consider word-boundary matching or an explicit allowlist if model names evolve beyond current conventions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 54:

<comment>`quotaKeyForModel` uses `String.includes()` to match pool keywords, so model names containing "spark", "opus", or "sonnet" as substrings would silently route to the wrong quota pool. Consider word-boundary matching or an explicit allowlist if model names evolve beyond current conventions.</comment>

<file context>
@@ -0,0 +1,163 @@
+  const normalized = model?.trim().toLowerCase()
+  if (!normalized) return "default"
+  for (const pool of MODEL_QUOTA_POOLS) {
+    if (normalized.includes(pool)) return pool
+  }
+  return "default"
</file context>

@@ -0,0 +1,3022 @@
import { DurableObject } from "cloudflare:workers"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: listTranscriptSummaries and transcriptDashboardData duplicate the full transcript-summary-building loop — iterating rows, constructing compound keys with baseSessionId, building MutableTranscriptSummary via applyTranscriptEvent, and sorting with the same comparator. Extraction into a shared buildTranscriptSummaries(sql, orgId) helper would eliminate the duplication and keep the two endpoints consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/worker/src/index.ts:

<comment>`listTranscriptSummaries` and `transcriptDashboardData` duplicate the full transcript-summary-building loop — iterating rows, constructing compound keys with `baseSessionId`, building `MutableTranscriptSummary` via `applyTranscriptEvent`, and sorting with the same comparator. Extraction into a shared `buildTranscriptSummaries(sql, orgId)` helper would eliminate the duplication and keep the two endpoints consistent.</comment>

@lawrencecchen lawrencecchen merged commit 52452c3 into main Jul 3, 2026
8 checks passed
@lawrencecchen lawrencecchen deleted the feat-cloudflare-do branch July 3, 2026 10:30
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.

1 participant