feat: add tenant-router Cloudflare Worker with routing for classroomio domains and update dependencies

This commit is contained in:
Rotimi Best
2026-05-19 23:53:51 +01:00
parent 0f388ae0d3
commit bad837a4cb
13 changed files with 225 additions and 37 deletions
-1
View File
@@ -11,7 +11,6 @@ const envSchema = z.object({
CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
+1 -1
View File
@@ -6,7 +6,7 @@ export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
? env.TRUSTED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com'];
: ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com', 'https://*.classroomio.school'];
const {
CLOUDFLARE_BUCKET_DOMAIN,
+8 -14
View File
@@ -238,7 +238,7 @@ export function assertSupportedCustomDomain(domain: string) {
);
}
if (domain.includes('classroomio.com')) {
if (domain.includes('classroomio.com') || domain.includes('classroomio.school')) {
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
}
}
@@ -252,21 +252,15 @@ async function getCustomHostnameByDomain(hostname: string) {
}
async function createCustomHostname(hostname: string) {
const body: Record<string, unknown> = {
hostname,
ssl: {
method: 'txt',
type: 'dv'
}
};
if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) {
body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN;
}
return cloudflareRequest<CloudflareCustomHostname>('', {
method: 'POST',
body: JSON.stringify(body)
body: JSON.stringify({
hostname,
ssl: {
method: 'txt',
type: 'dv'
}
})
});
}
@@ -99,7 +99,7 @@ function isURLCustomDomain(url: URL) {
return false;
}
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean);
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'classroomio.school'].filter(Boolean);
return !notCustomDomainHosts.some((host) => url.host.endsWith(host));
}
+3
View File
@@ -0,0 +1,3 @@
node_modules
.wrangler
.dev.vars
+69
View File
@@ -0,0 +1,69 @@
# @cio/tenant-router
Cloudflare Worker that fronts every browser-facing ClassroomIO host:
- `*.classroomio.school/*` — free-tier tenant sites
- `classroomio.school/*` — apex (301 → `classroomio.com`)
- `app.classroomio.com/*` — admin dashboard
- BYOD customer domains via Cloudflare for SaaS
The Worker forwards each request to one of two Render services based on path:
| Path | Upstream |
|---|---|
| `/api/*` | API service (`/api` prefix stripped) |
| anything else | Dashboard service |
It preserves the original host as `X-Forwarded-Host` so:
- The dashboard's SvelteKit adapter-node reads it via `HOST_HEADER=x-forwarded-host` and `event.url.host` becomes the tenant/admin/BYOD host.
- Better Auth on the API emits session cookies without a `Domain` attribute (see `packages/db/src/auth.ts` — `crossSubDomainCookies: { enabled: false }`), so the browser scopes each session to the exact host it called. Tenants on `acme.classroomio.school`, the admin app on `app.classroomio.com`, and BYOD `learn.acme.com` each get host-only sessions.
## Deploy
```bash
pnpm --filter @cio/tenant-router deploy
```
Live tail:
```bash
pnpm --filter @cio/tenant-router tail
```
## Env vars (wrangler.toml `[vars]`)
| Var | Value |
|---|---|
| `DASHBOARD_UPSTREAM_HOST` | `.onrender.com` host of the dashboard service (no scheme) |
| `API_UPSTREAM_HOST` | `.onrender.com` host of the API service |
| `APEX_REDIRECT_TARGET` | URL to redirect `classroomio.school` apex to (default `https://classroomio.com`) |
Update the values in `wrangler.toml` after looking them up in the Render dashboard.
## Cloudflare DNS expected setup
`classroomio.school` zone (new):
- `*` CNAME → any valid target, **Proxied**
- `@` CNAME → any valid target, **Proxied** (Worker serves the 301)
- `cname` CNAME → any valid target, **Proxied** — this is the value BYOD customers point their CNAME at
`classroomio.com` zone:
- `app` CNAME → **Proxied** so the route binds
- `api` CNAME → **DNS only** (server-to-server callers and webhooks bypass the Worker)
- The old wildcard `*` CNAME on `classroomio.com` should be deleted — tenant traffic now belongs on `classroomio.school`.
## Cloudflare for SaaS (BYOD)
On the `classroomio.school` zone:
- **SSL/TLS → Custom Hostnames**: enabled
- **Fallback Origin**: `cname.classroomio.school` — BYOD traffic lands on the Worker route and is forwarded through the same code path as `*.classroomio.school`.
The API uses these env vars (`apps/api/src/services/org/domain.ts`):
- `CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN` — Cloudflare API token scoped to the `classroomio.school` zone with `Zone → SSL and Certificates → Edit` + `Zone → Zone → Read`.
- `CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID` — Zone ID for `classroomio.school`.
- `CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET=cname.classroomio.school`.
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@cio/tenant-router",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"typescript": "^5.5.2",
"wrangler": "4.21.x"
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* @cio/tenant-router — Cloudflare Worker that fronts every browser-facing host.
*
* Routes (see wrangler.toml):
* - *.classroomio.school/* — free-tier tenant sites (acme.classroomio.school)
* - classroomio.school/* — apex (301 → classroomio.com)
* - app.classroomio.com/* — admin dashboard
* - <BYOD>/* — customer-owned domains via Cloudflare for SaaS
*
* Per request, the Worker decides upstream by path:
* - /api/* → API service (strips the /api prefix)
* - else → Dashboard service
*
* It preserves the original Host as X-Forwarded-Host so the dashboard
* (SvelteKit adapter-node `HOST_HEADER`) and Better Auth see the host the
* browser actually called. Auth cookies are emitted without a Domain
* attribute upstream, so they scope host-only on every domain naturally.
*/
interface Env {
DASHBOARD_UPSTREAM_HOST: string;
API_UPSTREAM_HOST: string;
APEX_REDIRECT_TARGET: string;
}
const API_PREFIX = '/api';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const originalHost = request.headers.get('host') ?? url.host;
// Apex on classroomio.school → permanent redirect to marketing apex.
if (originalHost === 'classroomio.school') {
const target = new URL(env.APEX_REDIRECT_TARGET);
target.pathname = url.pathname;
target.search = url.search;
return Response.redirect(target.toString(), 301);
}
const isApiCall = url.pathname === API_PREFIX || url.pathname.startsWith(`${API_PREFIX}/`);
const upstreamHost = isApiCall ? env.API_UPSTREAM_HOST : env.DASHBOARD_UPSTREAM_HOST;
const upstreamPath = isApiCall ? url.pathname.slice(API_PREFIX.length) || '/' : url.pathname;
const upstreamUrl = new URL(request.url);
upstreamUrl.protocol = 'https:';
upstreamUrl.host = upstreamHost;
upstreamUrl.pathname = upstreamPath;
const upstreamHeaders = new Headers(request.headers);
upstreamHeaders.set('host', upstreamHost);
upstreamHeaders.set('x-forwarded-host', originalHost);
upstreamHeaders.set('x-forwarded-proto', 'https');
const clientIp = request.headers.get('cf-connecting-ip');
if (clientIp) {
upstreamHeaders.set('x-forwarded-for', clientIp);
}
return fetch(upstreamUrl.toString(), {
method: request.method,
headers: upstreamHeaders,
body: request.body,
redirect: 'manual'
});
}
} satisfies ExportedHandler<Env>;
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
name = "cio-tenant-router"
main = "src/index.ts"
compatibility_date = "2026-05-01"
# Production routes — bind the Worker to every browser-facing host.
# Tenant subdomains and the apex live on the `classroomio.school` zone.
# The admin app `app.classroomio.com` is routed through the Worker so
# it can use same-origin `/api/*` paths with host-only auth cookies.
routes = [
{ pattern = "*.classroomio.school/*", zone_name = "classroomio.school" },
{ pattern = "classroomio.school/*", zone_name = "classroomio.school" },
{ pattern = "app.classroomio.com/*", zone_name = "classroomio.com" }
]
# Override per environment. Both values are the `.onrender.com` hostnames
# of the dashboard and API services (no scheme, no path).
[vars]
# Despite the name, `cio-api.onrender.com` is the **dashboard** service
# (apps/dashboard). The actual API service is `cio-api-vymh.onrender.com`.
DASHBOARD_UPSTREAM_HOST = "cio-api.onrender.com"
API_UPSTREAM_HOST = "cio-api-vymh.onrender.com"
APEX_REDIRECT_TARGET = "https://classroomio.com"
[observability]
enabled = true
+1 -4
View File
@@ -58,10 +58,7 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
},
advanced: {
cookiePrefix: 'classroomio',
crossSubDomainCookies: {
enabled: true,
...(CONSTANTS.AUTH_COOKIE_DOMAIN ? { domain: CONSTANTS.AUTH_COOKIE_DOMAIN } : {})
},
crossSubDomainCookies: { enabled: false },
database: {
generateId: false
}
+2 -7
View File
@@ -1,12 +1,9 @@
const {
TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING,
PUBLIC_SERVER_URL,
AUTH_COOKIE_DOMAIN: AUTH_COOKIE_DOMAIN_STRING
} = process.env;
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, PUBLIC_SERVER_URL } = process.env;
const DEFAULT_TRUSTED_ORIGINS = [
'http://localhost:5173',
'https://*.classroomio.com',
'https://*.classroomio.school',
// Common OIDC IdP discovery origins (SSO registration fetches .well-known/openid-configuration)
'https://accounts.google.com',
'https://account.google.com', // common typo for Google
@@ -29,5 +26,3 @@ export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
: DEFAULT_TRUSTED_ORIGINS;
export const BASE_URL = PUBLIC_SERVER_URL || 'http://localhost:3002';
export const AUTH_COOKIE_DOMAIN = AUTH_COOKIE_DOMAIN_STRING?.trim() || undefined;
+15 -9
View File
@@ -778,6 +778,18 @@ importers:
specifier: ^4.1.8
version: 4.3.6
apps/tenant-router:
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250109.0
version: 4.20260511.1
typescript:
specifier: ^5.5.2
version: 5.9.3
wrangler:
specifier: 4.21.x
version: 4.21.2(@cloudflare/[email protected])([email protected])([email protected])
apps/website:
dependencies:
'@cio/certificates':
@@ -13220,9 +13232,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
[email protected]:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
[email protected]:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@@ -25127,7 +25136,7 @@ snapshots:
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
ufo: 1.6.3
[email protected]([email protected]):
dependencies:
@@ -27647,10 +27656,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected]:
optional: true
[email protected].3: {}
[email protected]:
optional: true
@@ -27690,7 +27696,7 @@ snapshots:
exsolve: 1.0.8
ohash: 2.0.11
pathe: 2.0.3
ufo: 1.6.1
ufo: 1.6.3
[email protected]:
dependencies: