feat: add tenant-router Cloudflare Worker with routing for classroomio domains and update dependencies
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.wrangler
|
||||
.dev.vars
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+15
-9
@@ -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/workers-types@4.20260511.1)(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
|
||||
apps/website:
|
||||
dependencies:
|
||||
'@cio/certificates':
|
||||
@@ -13220,9 +13232,6 @@ packages:
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
|
||||
ufo@1.6.3:
|
||||
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
|
||||
|
||||
mode-watcher@1.1.0(svelte@5.46.0):
|
||||
dependencies:
|
||||
@@ -27647,10 +27656,7 @@ snapshots:
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
|
||||
ufo@1.6.3:
|
||||
optional: true
|
||||
ufo@1.6.3: {}
|
||||
|
||||
uglify-js@3.19.3:
|
||||
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
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user