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
|
||||
Reference in New Issue
Block a user