Skip to content

Commit 23e05fb

Browse files
Multi-tenant Cloudflare subrouter with tenant registry and account upload API (#52)
* Multi-tenant Cloudflare subrouter: tenant registry, hashed srt_ keys, account upload API - TenantRegistryDurableObject: create/list/rotate/revoke with sha256-hashed srt_* keys, admin-token gated - Data-plane auth requires a tenant key; per-tenant DO state namespaced as tenant:<id> so registry ids can never collide with the bare legacy/demo-org DOs - POST/GET/DELETE /tenant/accounts for Claude OAuth, Anthropic API key, Codex OAuth, OpenAI API key; responses sanitized, refresh alarms reused - Legacy shared PROXY_TOKEN disabled unless ALLOW_LEGACY_PROXY_TOKEN=1, routes only to tenant 'legacy' - Tenant-key headers stripped from upstream + transcript paths - Tests: lifecycle, upload, isolation (incl. hostile X-Subrouter-Org-ID and body orgId spoofing), legacy-token default-off, reserved-name collision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address PR 52 review findings: provider-aware routing, quota pool seeding, validation timeout, shared test helpers - Seed bootstrap quota pools on tenant account upload (anthropic: opus+sonnet, codex: spark) so uploaded accounts serve pooled models immediately - Filter account selection by upstream provider family on every proxy, /route, and WS route path (incl. sticky/preferred) so mixed-provider tenants never cross upstreams - Bounded, injectable timeout on ?validate=1 upstream usage fetch - Shared worker test helper with process-tree teardown + temp dir cleanup; isolation tests use default-pool models so the provider filter itself is exercised * Restore drain-aware readiness per tenant /_subrouter/ready?tenant=<id> consults the tenant DO's drain state (503 while draining, boolean only, no tenant data); bare /_subrouter/ready stays service-level --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f8d0300 commit 23e05fb

12 files changed

Lines changed: 2287 additions & 284 deletions
Lines changed: 182 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
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
23170
bun 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-
32173
Required 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`.

cloudflare/packages/worker/src/contract.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const subrouterHeadersToStrip = [
117117
"X-Model",
118118
"X-Subrouter-Org-ID",
119119
"X-Regatta-Org-ID",
120+
"X-Subrouter-Tenant-Key",
120121
"X-Subrouter-Proxy-Token",
121122
] as const
122123

0 commit comments

Comments
 (0)