diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index ccbc8984..73f7b2b2 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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(), diff --git a/apps/api/src/constants/index.ts b/apps/api/src/constants/index.ts index 50ffa991..fa12f6cb 100644 --- a/apps/api/src/constants/index.ts +++ b/apps/api/src/constants/index.ts @@ -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, diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts index e398d81b..ceb2d486 100644 --- a/apps/api/src/services/org/domain.ts +++ b/apps/api/src/services/org/domain.ts @@ -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 = { - hostname, - ssl: { - method: 'txt', - type: 'dv' - } - }; - - if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) { - body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN; - } - return cloudflareRequest('', { method: 'POST', - body: JSON.stringify(body) + body: JSON.stringify({ + hostname, + ssl: { + method: 'txt', + type: 'dv' + } + }) }); } diff --git a/apps/dashboard/src/lib/features/app/layout-setup.ts b/apps/dashboard/src/lib/features/app/layout-setup.ts index d889503f..23962967 100644 --- a/apps/dashboard/src/lib/features/app/layout-setup.ts +++ b/apps/dashboard/src/lib/features/app/layout-setup.ts @@ -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)); } diff --git a/apps/tenant-router/.gitignore b/apps/tenant-router/.gitignore new file mode 100644 index 00000000..073e02da --- /dev/null +++ b/apps/tenant-router/.gitignore @@ -0,0 +1,3 @@ +node_modules +.wrangler +.dev.vars diff --git a/apps/tenant-router/README.md b/apps/tenant-router/README.md new file mode 100644 index 00000000..82378cb0 --- /dev/null +++ b/apps/tenant-router/README.md @@ -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`. diff --git a/apps/tenant-router/package.json b/apps/tenant-router/package.json new file mode 100644 index 00000000..5f51a2f6 --- /dev/null +++ b/apps/tenant-router/package.json @@ -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" + } +} diff --git a/apps/tenant-router/src/index.ts b/apps/tenant-router/src/index.ts new file mode 100644 index 00000000..18877631 --- /dev/null +++ b/apps/tenant-router/src/index.ts @@ -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 + * - /* — 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 { + 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; diff --git a/apps/tenant-router/tsconfig.json b/apps/tenant-router/tsconfig.json new file mode 100644 index 00000000..960d6841 --- /dev/null +++ b/apps/tenant-router/tsconfig.json @@ -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"] +} diff --git a/apps/tenant-router/wrangler.toml b/apps/tenant-router/wrangler.toml new file mode 100644 index 00000000..bc8f5675 --- /dev/null +++ b/apps/tenant-router/wrangler.toml @@ -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 diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 8e710789..e6e1e21b 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -58,10 +58,7 @@ export const auth: ReturnType = betterAuth({ }, advanced: { cookiePrefix: 'classroomio', - crossSubDomainCookies: { - enabled: true, - ...(CONSTANTS.AUTH_COOKIE_DOMAIN ? { domain: CONSTANTS.AUTH_COOKIE_DOMAIN } : {}) - }, + crossSubDomainCookies: { enabled: false }, database: { generateId: false } diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts index cb10bcb6..d1c55d85 100644 --- a/packages/db/src/constants.ts +++ b/packages/db/src/constants.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d585ef8..5bd12907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: