11# @subrouter/cloudflare-worker
22
3- Cloudflare ** Durable Objects** subrouter: a per-org subrouter service with
4- sticky session affinity , per-model-family quota pools, Go-compatible
5- ` /_subrouter/* ` admin endpoints, and upstream proxying for Codex/OpenAI and
6- Claude/Anthropic accounts.
3+ Cloudflare ** Durable Objects** subrouter: a multi-tenant routing service with
4+ hashed tenant keys , per-tenant Durable Object state, sticky session affinity,
5+ OAuth refresh alarms, Go-compatible ` /_subrouter/* ` admin endpoints, and
6+ upstream proxying for Codex/OpenAI and Claude/Anthropic accounts.
77
88## Environments
99
@@ -13,8 +13,155 @@ Claude/Anthropic accounts.
1313| staging | ` regatta-subrouter-do-staging ` | https://subrouter-staging.cmux.dev |
1414| production | ` regatta-subrouter-do-production ` | https://subrouter.cmux.dev |
1515
16- Each env is a distinct Worker with its own Durable Object namespace (isolated
17- SQLite state), its own admin/proxy tokens, and its own Durable Object alarms.
16+ Each env is a distinct Worker with its own Durable Object namespaces and
17+ SQLite state. Registry tenants store tenant-scoped accounts, sticky sessions,
18+ usage, transcripts, quotas, lifecycle, and alarms under
19+ ` SUBROUTER_DO.getByName("tenant:" + <tenant id>) ` ; historical bare names such
20+ as ` legacy ` and ` demo-org ` remain reserved for compatibility state.
21+ ` TENANT_REGISTRY_DO.getByName("registry") ` stores only tenant metadata and
22+ SHA-256 hashes of tenant keys.
23+
24+ ## Auth Model
25+
26+ ` ADMIN_TOKEN ` gates ` /admin/* ` , ` /_subrouter/* ` , and ` /websocket-status ` .
27+
28+ Data-plane requests require a tenant key in either:
29+
30+ ``` sh
31+ Authorization: Bearer srt_< 32 lowercase hex chars>
32+ X-Subrouter-Tenant-Key: srt_< 32 lowercase hex chars>
33+ ```
34+
35+ Tenant keys are returned only when created or rotated. The registry stores
36+ ` sha256(key) ` hex, never the plaintext key. Revoked keys return ` 403 ` ; missing,
37+ malformed, unknown, and rotated-away keys return ` 401 ` .
38+
39+ Legacy shared-token access is disabled by default. To temporarily allow the old
40+ ` PROXY_TOKEN ` path, set ` ALLOW_LEGACY_PROXY_TOKEN=1 ` and ` PROXY_TOKEN ` ; those
41+ requests route only to tenant id ` legacy ` .
42+
43+ ## Tenant Lifecycle
44+
45+ Create a tenant:
46+
47+ ``` sh
48+ curl -sS https://subrouter.cmux.dev/admin/tenants \
49+ -H " Authorization: Bearer $ADMIN_TOKEN " \
50+ -H " Content-Type: application/json" \
51+ -d ' {"name":"Acme"}'
52+ ```
53+
54+ Response:
55+
56+ ``` json
57+ {"id" :" acme" ,"name" :" Acme" ,"key" :" srt_0123456789abcdef0123456789abcdef" ,"created_at" :1783123200000 ,"revoked_at" :null }
58+ ```
59+
60+ List tenants, without keys or hashes:
61+
62+ ``` sh
63+ curl -sS https://subrouter.cmux.dev/admin/tenants \
64+ -H " Authorization: Bearer $ADMIN_TOKEN "
65+ ```
66+
67+ Rotate a tenant key:
68+
69+ ``` sh
70+ curl -sS -X POST https://subrouter.cmux.dev/admin/tenants/acme/rotate \
71+ -H " Authorization: Bearer $ADMIN_TOKEN "
72+ ```
73+
74+ Revoke a tenant:
75+
76+ ``` sh
77+ curl -sS -X POST https://subrouter.cmux.dev/admin/tenants/acme/revoke \
78+ -H " Authorization: Bearer $ADMIN_TOKEN "
79+ ```
80+
81+ ## Account Upload API
82+
83+ Upload Claude OAuth credentials:
84+
85+ ``` sh
86+ curl -sS https://subrouter.cmux.dev/tenant/accounts \
87+ -H " Authorization: Bearer $TENANT_KEY " \
88+ -H " Content-Type: application/json" \
89+ -d ' {"provider":"claude","label":"alice@example.com","claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":1735689600000,"subscriptionType":"max","rateLimitTier":"..."}}'
90+ ```
91+
92+ Upload an Anthropic API key:
93+
94+ ``` sh
95+ curl -sS https://subrouter.cmux.dev/tenant/accounts \
96+ -H " Authorization: Bearer $TENANT_KEY " \
97+ -H " Content-Type: application/json" \
98+ -d ' {"provider":"anthropic-apikey","label":"anthropic prod","apiKey":"sk-ant-..."}'
99+ ```
100+
101+ Upload Codex OAuth tokens:
102+
103+ ``` sh
104+ curl -sS https://subrouter.cmux.dev/tenant/accounts \
105+ -H " Authorization: Bearer $TENANT_KEY " \
106+ -H " Content-Type: application/json" \
107+ -d ' {"provider":"codex","label":"codex@example.com","tokens":{"accessToken":"...","refreshToken":"...","idToken":"...","accountID":"..."}}'
108+ ```
109+
110+ Upload an OpenAI API key:
111+
112+ ``` sh
113+ curl -sS https://subrouter.cmux.dev/tenant/accounts \
114+ -H " Authorization: Bearer $TENANT_KEY " \
115+ -H " Content-Type: application/json" \
116+ -d ' {"provider":"openai-apikey","label":"openai prod","apiKey":"sk-..."}'
117+ ```
118+
119+ Responses are sanitized:
120+
121+ ``` json
122+ {"id" :" claude-..." ,"provider" :" claude" ,"label" :" alice@example.com" ,"auth_mode" :" oauth" ,"created_at" :" 2026-07-03T00:00:00.000Z" ,"email" :" alice@example.com" }
123+ ```
124+
125+ List and delete accounts:
126+
127+ ``` sh
128+ curl -sS https://subrouter.cmux.dev/tenant/accounts \
129+ -H " Authorization: Bearer $TENANT_KEY "
130+
131+ curl -sS -X DELETE https://subrouter.cmux.dev/tenant/accounts/$ACCOUNT_ID \
132+ -H " Authorization: Bearer $TENANT_KEY "
133+ ```
134+
135+ OAuth uploads schedule the existing Durable Object alarm refresh path. Rotated
136+ refresh tokens are persisted in the tenant DO.
137+
138+ ## Admin Tenant Data
139+
140+ Tenant-scoped admin reads require an explicit ` ?tenant=<id> ` :
141+
142+ ``` sh
143+ curl -sS " https://subrouter.cmux.dev/_subrouter/accounts?tenant=acme" \
144+ -H " Authorization: Bearer $ADMIN_TOKEN "
145+
146+ curl -sS " https://subrouter.cmux.dev/_subrouter/transcripts?tenant=acme" \
147+ -H " Authorization: Bearer $ADMIN_TOKEN "
148+ ```
149+
150+ ` GET /_subrouter/health ` and bare ` GET /_subrouter/ready ` are
151+ unauthenticated and do not return tenant data. ` GET /_subrouter/ready?tenant=<id> `
152+ is also unauthenticated and returns only ` { ok, draining } ` for load-balancer
153+ and rollout probes; malformed tenant ids return ` 400 ` , while well-formed unknown
154+ ids return ready because no tenant state exists yet.
155+
156+ ## cmux CLI Call Sequence
157+
158+ The follow-up cmux integration should call:
159+
160+ 1 . Admin side: ` POST /admin/tenants ` to create the tenant and show the key once.
161+ 2 . Tenant side: ` POST /tenant/accounts ` to upload Claude/Codex OAuth or API-key credentials.
162+ 3 . Tenant side: ` GET /tenant/accounts ` to show sanitized configured accounts.
163+ 4 . Tenant side: ` DELETE /tenant/accounts/:id ` to remove an account.
164+ 5 . Data plane: proxy requests with ` Authorization: Bearer $TENANT_KEY ` .
18165
19166## Deploy
20167
@@ -23,76 +170,56 @@ bun run deploy:staging
23170bun run deploy:production
24171```
25172
26- GitHub Actions runs ` Cloudflare Durable Object ` for changes under
27- ` cloudflare/** ` . Pull requests run install, typecheck, tests, and a Wrangler
28- dry-run bundle validation. Pushes to ` main ` deploy staging first, smoke
29- ` https://subrouter-staging.cmux.dev/healthz ` , then deploy production and smoke
30- ` https://subrouter.cmux.dev/healthz ` .
31-
32173Required Actions secrets:
33174
34175- ` CLOUDFLARE_ACCOUNT_ID ` - Cloudflare account ID for Wrangler.
35176- ` CLOUDFLARE_API_TOKEN ` - long-lived Cloudflare API token with Workers deploy
36177 permissions for both ` regatta-subrouter-do-staging ` and
37178 ` regatta-subrouter-do-production ` .
38179
39- ## Secrets
180+ ## Local Dev
40181
41- ` ADMIN_TOKEN ` gates the ` /admin/* ` , ` /_subrouter/* ` , and ` /websocket-status `
42- endpoints. ` PROXY_TOKEN ` gates upstream proxy requests when present; if it is
43- unset, proxy requests use ` ADMIN_TOKEN ` . Set a separate proxy token when clients
44- should not share the admin credential:
182+ Create ` .dev.vars ` (gitignored) with at least:
45183
46184``` sh
47- bun run secret:admin:staging
48- bun run secret:admin:production
49- bun run secret:proxy:staging
50- bun run secret:proxy:production
185+ ADMIN_TOKEN=< anything>
51186```
52187
53- ## Local dev
54-
55- Create ` .dev.vars ` (gitignored) with ` ADMIN_TOKEN=<anything> ` and
56- ` PROXY_TOKEN=<anything> ` then:
188+ Optional legacy compatibility:
57189
58190``` sh
59- bun run dev # local workerd + DO SQLite on :8787
191+ PROXY_TOKEN=< anything>
192+ ALLOW_LEGACY_PROXY_TOKEN=1
60193```
61194
62- ## OAuth refresh
63-
64- OAuth account credentials can include ` accessToken ` , ` refreshToken ` ,
65- ` expiresAt ` , ` tokenEndpoint ` , and ` clientId ` . The Durable Object refreshes
66- expired Codex and Claude OAuth credentials on use, persists rotated refresh
67- tokens atomically in SQLite, and schedules the next refresh with the Durable
68- Object Alarms API. Alarms wake the object in the future; long sleeps are not
69- used.
70-
71- ## Logs
195+ Then run:
72196
73197``` sh
74- bun run tail:staging # wrangler tail --env staging
75- bun run tail:production
198+ bun run dev
76199```
77200
78201## Endpoints
79202
80203- ` GET /healthz `
81204- ` GET /_subrouter/health `
82- - ` GET /_subrouter/ready `
83- - ` POST /_subrouter/drain `
84- - ` GET /_subrouter/drain-status `
85- - ` GET /_subrouter/accounts `
86- - ` GET|POST /_subrouter/account-status `
87- - ` GET /_subrouter/usage-status `
88- - ` POST /_subrouter/reload-accounts `
89- - ` GET /_subrouter/sessions `
90- - ` GET /_subrouter/dashboard `
91- - ` GET /_subrouter/transcripts `
92- - ` GET /_subrouter/transcripts/:agent/:session `
93- - ` GET /_subrouter/transcripts/:agent/:session/raw `
94- - ` POST /route ` — ` { orgId, sessionId, model?, preferAccountId?, quotaKey? } ` → selected account
95- - ` POST /usage ` — ` { orgId, sessionId, accountId } `
96- - ` GET /status?orgId= ` — DO route counters
97- - ` GET /ws?orgId= ` — WebSocket; messages ` { type: "ping"|"route"|"usage"|"status", ... } `
98- - ` GET /admin/accounts ` , ` POST /admin/accounts ` , ` POST /admin/model-probe ` , ` GET /websocket-status ` — require ` Authorization: Bearer $ADMIN_TOKEN `
205+ - ` GET /_subrouter/ready ` - service-level readiness, no tenant data.
206+ - ` GET /_subrouter/ready?tenant= ` - tenant drain readiness, returns 503 when draining; malformed tenant ids return 400.
207+ - ` POST /admin/tenants ` , ` GET /admin/tenants `
208+ - ` POST /admin/tenants/:id/rotate ` , ` POST /admin/tenants/:id/revoke `
209+ - ` POST /tenant/accounts ` , ` GET /tenant/accounts ` , ` DELETE /tenant/accounts/:id `
210+ - ` POST /_subrouter/drain?tenant= `
211+ - ` GET /_subrouter/drain-status?tenant= `
212+ - ` GET /_subrouter/accounts?tenant= `
213+ - ` GET|POST /_subrouter/account-status?tenant= `
214+ - ` GET /_subrouter/usage-status?tenant= `
215+ - ` POST /_subrouter/reload-accounts?tenant= `
216+ - ` GET /_subrouter/sessions?tenant= `
217+ - ` GET /_subrouter/dashboard?tenant= `
218+ - ` GET /_subrouter/transcripts?tenant= `
219+ - ` GET /_subrouter/transcripts/:agent/:session?tenant= `
220+ - ` GET /_subrouter/transcripts/:agent/:session/raw?tenant= `
221+ - ` POST /route ` - tenant-key authed route selection.
222+ - ` POST /usage ` - tenant-key authed usage recording.
223+ - ` GET /status ` - tenant-key authed DO route counters.
224+ - ` GET /ws ` - tenant-key authed WebSocket; messages ` { type: "ping"|"route"|"usage"|"status", ... } ` .
225+ - ` GET /websocket-status?tenant= ` , ` GET /admin/accounts?tenant= ` , ` POST /admin/accounts ` , ` POST /admin/model-probe ` - require ` Authorization: Bearer $ADMIN_TOKEN ` .
0 commit comments