From 61cbb258ea9cb0006bc9fd125569a92d56a3c4e1 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 21 May 2026 11:29:47 +0100 Subject: [PATCH] chore: move lms to myclassroomio.com and use approximated for custom domain --- apps/api/src/config/env.ts | 5 +- apps/api/src/constants/index.ts | 2 +- apps/api/src/services/org/domain.ts | 244 ++++++------------ .../src/services/organization/auto-enroll.ts | 2 +- .../src/lib/features/app/layout-setup.ts | 2 +- .../src/lib/utils/services/api/index.ts | 3 +- .../src/lib/utils/services/auth/client.ts | 3 +- apps/tenant-router/README.md | 27 +- apps/tenant-router/src/index.ts | 8 +- apps/tenant-router/wrangler.toml | 6 +- packages/db/GOOGLE_AUTH.md | 16 +- packages/db/src/auth.ts | 2 +- packages/db/src/constants.ts | 2 +- packages/utils/src/constants/domains.ts | 2 +- 14 files changed, 114 insertions(+), 210 deletions(-) diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 73f7b2b2..f4534f71 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -8,9 +8,8 @@ const envSchema = z.object({ CLOUDFLARE_RENDERING_API_KEY: z.string().optional(), CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(), CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(), - CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(), - CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(), - CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(), + APPROXIMATED_API_KEY: z.string().optional(), + APPROXIMATED_TARGET_ADDRESS: 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 fa12f6cb..08874efa 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', 'https://*.classroomio.school']; + : ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com', 'https://*.myclassroomio.com']; const { CLOUDFLARE_BUCKET_DOMAIN, diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts index ceb2d486..15851eca 100644 --- a/apps/api/src/services/org/domain.ts +++ b/apps/api/src/services/org/domain.ts @@ -1,6 +1,7 @@ -import { env } from '@api/config/env'; import { AppError, ErrorCodes } from '@api/utils/errors'; +import { env } from '@api/config/env'; + export type DomainSetupStatus = | 'reconnect_required' | 'pending_dns' @@ -10,7 +11,7 @@ export type DomainSetupStatus = | 'error'; export interface DomainDnsRecord { - type: 'CNAME' | 'TXT'; + type: 'A' | 'CNAME' | 'TXT'; name: string; value: string; status: 'pending' | 'active'; @@ -22,83 +23,67 @@ export interface DomainSetupResult { verified: boolean; reconnectRequired: boolean; message: string; - provider: 'cloudflare'; + provider: 'approximated'; dnsRecords: DomainDnsRecord[]; + validationErrors: string[]; } -interface CloudflareApiError { - code: number; - message: string; -} - -interface CloudflareEnvelope { - success: boolean; - errors: CloudflareApiError[]; - messages: Array<{ code: number; message: string }>; - result: T; -} - -interface CloudflareValidationRecord { +interface ApproximatedVhost { + incoming_address: string; + target_address?: string; + dns_pointed_at?: string; + has_ssl?: boolean; + is_resolving?: boolean; + apx_hit?: boolean; status?: string; - txt_name?: string; - txt_value?: string; -} - -interface CloudflareCustomHostname { - id: string; - hostname: string; - status?: string; - ownership_verification?: { - type?: string; - name?: string; - value?: string; - }; - ssl?: { - status?: string; - validation_records?: CloudflareValidationRecord[]; - }; + ssl_active_from?: string | null; + ssl_active_until?: string | null; } const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i; -function ensureCloudflareConfig() { +const APPROXIMATED_BASE_URL = 'https://cloud.approximated.app/api'; + +function ensureApproximatedConfig() { const missing = [ - ['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN], - ['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID], - ['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET] + ['APPROXIMATED_API_KEY', env.APPROXIMATED_API_KEY], + ['APPROXIMATED_TARGET_ADDRESS', env.APPROXIMATED_TARGET_ADDRESS] ].filter(([, value]) => !value); if (missing.length > 0) { throw new AppError( - `Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`, + `Missing Approximated config: ${missing.map(([key]) => key).join(', ')}`, ErrorCodes.INTERNAL_ERROR, 500 ); } } -function getCloudflareBasePath() { - ensureCloudflareConfig(); - return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`; -} +async function approximatedRequest(path: string, init: RequestInit = {}): Promise { + ensureApproximatedConfig(); -async function cloudflareRequest(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${getCloudflareBasePath()}${path}`, { + const response = await fetch(`${APPROXIMATED_BASE_URL}${path}`, { ...init, headers: { - Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`, + 'api-key': env.APPROXIMATED_API_KEY!, 'Content-Type': 'application/json', + Accept: 'application/json', ...(init.headers ?? {}) } }); - const payload = (await response.json()) as CloudflareEnvelope; + if (response.status === 404) { + return null; + } - if (!response.ok || !payload.success) { + const text = await response.text(); + const payload = text ? (JSON.parse(text) as unknown) : null; + + if (!response.ok) { const message = - payload.errors?.map((error) => error.message).join(', ') || - payload.messages?.map((item) => item.message).join(', ') || - 'Cloudflare domain request failed'; + (payload as { message?: string } | null)?.message ?? + (payload as { error?: string } | null)?.error ?? + `Approximated ${init.method ?? 'GET'} ${path} failed`; throw new AppError( message, @@ -107,94 +92,47 @@ async function cloudflareRequest(path: string, init: RequestInit = {}): Promi ); } - return payload.result; + return payload as T; } -function dedupeDnsRecords(records: DomainDnsRecord[]) { - const seen = new Set(); +function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] { + if (!vhost?.dns_pointed_at) return []; - return records.filter((record) => { - const key = `${record.type}:${record.name}:${record.value}`; - if (seen.has(key)) { - return false; - } - - seen.add(key); - return true; - }); -} - -function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] { - const records: DomainDnsRecord[] = [ + return [ { - type: 'CNAME', + type: 'A', name: hostname, - value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!, - status: customHostname?.status === 'active' ? 'active' : 'pending' + value: vhost.dns_pointed_at, + status: vhost.is_resolving ? 'active' : 'pending' } ]; - - if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) { - records.push({ - type: 'TXT', - name: customHostname.ownership_verification.name, - value: customHostname.ownership_verification.value, - status: customHostname.status === 'active' ? 'active' : 'pending' - }); - } - - customHostname?.ssl?.validation_records?.forEach((record) => { - if (!record.txt_name || !record.txt_value) { - return; - } - - records.push({ - type: 'TXT', - name: record.txt_name, - value: record.txt_value, - status: record.status === 'active' ? 'active' : 'pending' - }); - }); - - return dedupeDnsRecords(records); } -function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus { - if (!customHostname) { +function mapDomainStatus(vhost?: ApproximatedVhost | null): DomainSetupStatus { + if (!vhost) { return 'reconnect_required'; } - if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') { + if (vhost.has_ssl && vhost.is_resolving && vhost.apx_hit) { return 'verified'; } - const hasOwnershipRecord = Boolean( - customHostname.ownership_verification?.name && customHostname.ownership_verification?.value - ); - - if (hasOwnershipRecord) { + if (!vhost.is_resolving) { return 'pending_dns'; } - const hasSslValidationRecords = Boolean( - customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value) - ); - - if (hasSslValidationRecords || customHostname.ssl?.status) { - return 'pending_verification'; - } - - return 'error'; + // Resolving but cert/proxy still finalizing. + return 'pending_verification'; } function getStatusMessage(status: DomainSetupStatus) { switch (status) { case 'reconnect_required': - return 'Reconnect this custom domain to generate new Cloudflare setup records.'; + return 'Reconnect this custom domain to generate new setup records.'; case 'pending_dns': - return 'Add the DNS records below, then refresh verification.'; + return 'Add the DNS A record below, then refresh verification.'; case 'pending_verification': - return 'DNS records were found. SSL issuance or validation is still in progress.'; + return 'DNS resolves correctly. SSL issuance is still in progress.'; case 'verified': return 'Custom domain verified.'; case 'removed': @@ -205,8 +143,8 @@ function getStatusMessage(status: DomainSetupStatus) { } } -function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult { - const status = mapDomainStatus(customHostname); +function toDomainSetupResult(hostname: string, vhost?: ApproximatedVhost | null): DomainSetupResult { + const status = mapDomainStatus(vhost); return { hostname, @@ -214,8 +152,9 @@ function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustom verified: status === 'verified', reconnectRequired: status === 'reconnect_required', message: getStatusMessage(status), - provider: 'cloudflare', - dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname) + provider: 'approximated', + dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, vhost), + validationErrors: [] }; } @@ -238,74 +177,48 @@ export function assertSupportedCustomDomain(domain: string) { ); } - if (domain.includes('classroomio.com') || domain.includes('classroomio.school')) { + if (domain.includes('classroomio.com') || domain.includes('myclassroomio.com')) { throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain'); } } -async function getCustomHostnameByDomain(hostname: string) { - const results = await cloudflareRequest(`?hostname=${encodeURIComponent(hostname)}`, { +async function getVhost(hostname: string): Promise { + return approximatedRequest(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, { method: 'GET' }); - - return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null; } -async function createCustomHostname(hostname: string) { - return cloudflareRequest('', { +async function createVhost(hostname: string): Promise { + const created = await approximatedRequest('/vhosts', { method: 'POST', body: JSON.stringify({ - hostname, - ssl: { - method: 'txt', - type: 'dv' - } + incoming_address: hostname, + target_address: env.APPROXIMATED_TARGET_ADDRESS!, + target_ports: '443' }) }); -} -async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) { - return cloudflareRequest(`/${customHostname.id}`, { - method: 'PATCH', - body: JSON.stringify({ - ssl: { - method: 'txt', - type: 'dv' - } - }) - }); + if (!created) { + throw new AppError('Approximated did not return a vhost', ErrorCodes.INTERNAL_ERROR, 500); + } + return created; } export async function connectDomain(hostname: string): Promise { - const existing = await getCustomHostnameByDomain(hostname); - const customHostname = existing ?? (await createCustomHostname(hostname)); - - return toDomainSetupResult(hostname, customHostname); + const existing = await getVhost(hostname); + const vhost = existing ?? (await createVhost(hostname)); + return toDomainSetupResult(hostname, vhost); } export async function refreshDomain(hostname: string): Promise { - const existing = await getCustomHostnameByDomain(hostname); - - if (!existing) { - return toDomainSetupResult(hostname, null); - } - - if (existing.status === 'active' || existing.ssl?.status === 'active') { - return toDomainSetupResult(hostname, existing); - } - - const refreshed = await revalidateCustomHostname(existing); - return toDomainSetupResult(hostname, refreshed); + const vhost = await getVhost(hostname); + return toDomainSetupResult(hostname, vhost); } export async function removeDomain(hostname: string): Promise { - const existing = await getCustomHostnameByDomain(hostname); - - if (existing) { - await cloudflareRequest(`/${existing.id}`, { - method: 'DELETE' - }); - } + await approximatedRequest(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, { + method: 'DELETE' + }); return { hostname, @@ -313,7 +226,8 @@ export async function removeDomain(hostname: string): Promise verified: false, reconnectRequired: false, message: getStatusMessage('removed'), - provider: 'cloudflare', - dnsRecords: [] + provider: 'approximated', + dnsRecords: [], + validationErrors: [] }; } diff --git a/apps/api/src/services/organization/auto-enroll.ts b/apps/api/src/services/organization/auto-enroll.ts index fb35ddc5..525e13fa 100644 --- a/apps/api/src/services/organization/auto-enroll.ts +++ b/apps/api/src/services/organization/auto-enroll.ts @@ -21,7 +21,7 @@ export interface AutoEnrollResult { /** * Auto-enroll the authenticated user as a STUDENT in the given org. Used by * the dashboard when a newly-signed-in user lands on a tenant site they - * aren't yet a member of (free-tier `.classroomio.school` or a verified + * aren't yet a member of (free-tier `.myclassroomio.com` or a verified * BYOD domain). * * Idempotent: existing members get an `alreadyMember: true` no-op so invited diff --git a/apps/dashboard/src/lib/features/app/layout-setup.ts b/apps/dashboard/src/lib/features/app/layout-setup.ts index 23962967..6109bb32 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', 'classroomio.school'].filter(Boolean); + const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'myclassroomio.com'].filter(Boolean); return !notCustomDomainHosts.some((host) => url.host.endsWith(host)); } diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 0d6c2bd3..48d20b67 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -1,5 +1,6 @@ import { type ApiClientConfig, ApiError, type RequestConfig } from './types'; +import { dev } from '$app/environment'; import { DEFAULT_CONFIG } from './constants'; import { delay } from './utils'; import { hcWithType } from '@cio/api/rpc-types'; @@ -19,7 +20,7 @@ export const getRequestBaseUrl = () => { // Self-hosted: dashboard and API are on different subdomains of the // operator's apex. Browser calls go straight to PUBLIC_SERVER_URL; // cookies cross subdomains via AUTH_COOKIE_DOMAIN. No Worker proxy. - if (env.PUBLIC_IS_SELFHOSTED === 'true') { + if (env.PUBLIC_IS_SELFHOSTED === 'true' || dev) { return env.PUBLIC_SERVER_URL ?? ''; } diff --git a/apps/dashboard/src/lib/utils/services/auth/client.ts b/apps/dashboard/src/lib/utils/services/auth/client.ts index b114adf2..568dd2bb 100644 --- a/apps/dashboard/src/lib/utils/services/auth/client.ts +++ b/apps/dashboard/src/lib/utils/services/auth/client.ts @@ -1,6 +1,7 @@ import { createAuthClient } from 'better-auth/svelte'; import { env } from '$env/dynamic/public'; import { ssoClient } from '@better-auth/sso/client'; +import { dev } from '$app/environment'; // Browser baseURL depends on deployment shape: // @@ -22,7 +23,7 @@ function resolveBaseURL() { if (typeof window === 'undefined') { return env.PUBLIC_SERVER_URL || 'http://localhost:3002'; } - if (env.PUBLIC_IS_SELFHOSTED === 'true') { + if (env.PUBLIC_IS_SELFHOSTED === 'true' || dev) { return env.PUBLIC_SERVER_URL || `${window.location.origin}/api/auth`; } return `${window.location.origin}/proxy/api/auth`; diff --git a/apps/tenant-router/README.md b/apps/tenant-router/README.md index 73f9b6ed..79b64b94 100644 --- a/apps/tenant-router/README.md +++ b/apps/tenant-router/README.md @@ -2,10 +2,10 @@ Cloudflare Worker that fronts every browser-facing ClassroomIO host: -- `*.classroomio.school/*` — free-tier tenant sites -- `classroomio.school/*` — apex (301 → `classroomio.com`) +- `*.myclassroomio.com/*` — free-tier tenant sites +- `myclassroomio.com/*` — apex (301 → `classroomio.com`) - `app.classroomio.com/*` — admin dashboard -- BYOD customer domains via Cloudflare for SaaS +- BYOD customer domains via Approximated (their proxy forwards to `cio-api.onrender.com` directly, not through this Worker) The Worker forwards each request to one of two Render services based on path: @@ -23,7 +23,7 @@ the API. Example: a browser request to 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. +- 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.myclassroomio.com`, the admin app on `app.classroomio.com`, and BYOD `learn.acme.com` each get host-only sessions. ## Deploy @@ -43,33 +43,22 @@ pnpm --filter @cio/tenant-router tail |---|---| | `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`) | +| `APEX_REDIRECT_TARGET` | URL to redirect `myclassroomio.com` 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): +`myclassroomio.com` zone: - `*` 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) +## BYOD custom domains -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`. +Handled by [Approximated](https://approximated.app/), not this Worker. Cloudflare for SaaS can't front a Render origin (Render itself runs on Cloudflare), so we use Approximated's dedicated edge cluster instead. See `apps/api/src/services/org/domain.ts` for the integration. Customers point an A record at the IP Approximated returns; the proxy forwards directly to `cio-api.onrender.com`. diff --git a/apps/tenant-router/src/index.ts b/apps/tenant-router/src/index.ts index 74e1f79c..0692255b 100644 --- a/apps/tenant-router/src/index.ts +++ b/apps/tenant-router/src/index.ts @@ -2,8 +2,8 @@ * @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) + * - *.myclassroomio.com/* — free-tier tenant sites (acme.myclassroomio.com) + * - myclassroomio.com/* — apex (301 → classroomio.com) * - app.classroomio.com/* — admin dashboard * - /* — customer-owned domains via Cloudflare for SaaS * @@ -45,8 +45,8 @@ export default { 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') { + // Apex on myclassroomio.com → permanent redirect to marketing apex. + if (originalHost === 'myclassroomio.com') { const target = new URL(env.APEX_REDIRECT_TARGET); target.pathname = url.pathname; target.search = url.search; diff --git a/apps/tenant-router/wrangler.toml b/apps/tenant-router/wrangler.toml index bc8f5675..ea9b3521 100644 --- a/apps/tenant-router/wrangler.toml +++ b/apps/tenant-router/wrangler.toml @@ -3,12 +3,12 @@ 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. +# Tenant subdomains and the apex live on the `myclassroomio.com` 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 = "*.myclassroomio.com/*", zone_name = "myclassroomio.com" }, + { pattern = "myclassroomio.com/*", zone_name = "myclassroomio.com" }, { pattern = "app.classroomio.com/*", zone_name = "classroomio.com" } ] diff --git a/packages/db/GOOGLE_AUTH.md b/packages/db/GOOGLE_AUTH.md index 4ecb0ff8..06e918c1 100644 --- a/packages/db/GOOGLE_AUTH.md +++ b/packages/db/GOOGLE_AUTH.md @@ -3,21 +3,21 @@ This doc explains how Google sign-in works for ClassroomIO across three different host families: - `app.classroomio.com` — admin dashboard -- `.classroomio.school` — free-tier tenant sites +- `.myclassroomio.com` — free-tier tenant sites - `.com` — BYOD custom domains via Cloudflare for SaaS All three need to sign users in via Google, set host-only auth cookies on the originating host, and land the user back on the page they started from. ## The problem -Google OAuth requires every callback URL to be **registered ahead of time** in Google Cloud Console. Wildcards are not allowed in standard apps. We can't register `*.classroomio.school` or every BYOD customer's domain. +Google OAuth requires every callback URL to be **registered ahead of time** in Google Cloud Console. Wildcards are not allowed in standard apps. We can't register `*.myclassroomio.com` or every BYOD customer's domain. -We also can't share auth cookies across apex domains: a cookie on `api.classroomio.com` won't be sent to a request on `acme.classroomio.school`. Even setting `Domain=.classroomio.school` won't help — the OAuth callback always lands on `api.classroomio.com` per the registered URI, and that response can only set cookies for `api.classroomio.com`. +We also can't share auth cookies across apex domains: a cookie on `api.classroomio.com` won't be sent to a request on `acme.myclassroomio.com`. Even setting `Domain=.myclassroomio.com` won't help — the OAuth callback always lands on `api.classroomio.com` per the registered URI, and that response can only set cookies for `api.classroomio.com`. ## The architecture ``` -acme.classroomio.school api.classroomio.com Google +acme.myclassroomio.com api.classroomio.com Google ───────────────────────── ───────────────────── ────── POST /proxy/api/auth/sign-in/social @@ -29,7 +29,7 @@ POST /proxy/api/auth/sign-in/social │ oAuthProxy plugin sees │ currentURL ≠ productionURL, │ rewrites callbackURL to - │ acme.classroomio.school/ + │ acme.myclassroomio.com/ │ api/auth/oauth-proxy-callback │ and forces redirect_uri to │ api.classroomio.com/.../callback/google @@ -76,7 +76,7 @@ POST /proxy/api/auth/sign-in/social │ tenant host) ▼ ◀────────────────────────────────────── 302 to original callbackURL ────────────────────────── - browser lands on acme.classroomio.school/, logged in. + browser lands on acme.myclassroomio.com/, logged in. ``` The same flow works for `app.classroomio.com` (the proxy-skip branch in `oAuthProxy` kicks in when `currentURL === productionURL`) and for BYOD domains (`learn.acme.com`) routed through Cloudflare for SaaS. @@ -116,7 +116,7 @@ You only ever need **one** OAuth client and **one** registered callback URI for - **Authorized JavaScript origins**: `https://api.classroomio.com` - **Authorized redirect URIs**: `https://api.classroomio.com/api/auth/callback/google` -Do **not** add tenant subdomains, `*.classroomio.school`, or BYOD customer domains — Google rejects wildcards for standard apps and the `oAuthProxy` plugin handles the redirect to the original tenant host on its own. +Do **not** add tenant subdomains, `*.myclassroomio.com`, or BYOD customer domains — Google rejects wildcards for standard apps and the `oAuthProxy` plugin handles the redirect to the original tenant host on its own. ### Sensitive scopes warning @@ -139,7 +139,7 @@ On the **dashboard** Render service: | -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. | | `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. | -| `PRIVATE_APP_HOST` | `classroomio.school` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. | +| `PRIVATE_APP_HOST` | `myclassroomio.com` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. | | `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. | | `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. | diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index fe033440..3d3d7e26 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -22,7 +22,7 @@ import { trackLoginHook } from './auth/hooks/track-login'; /** * Cloud (multi-tenant) only. Routes OAuth/SSO callbacks to the canonical * production URL while completing the flow on whichever tenant host the - * user signed in from (.classroomio.school or a BYOD domain). + * user signed in from (.myclassroomio.com or a BYOD domain). * * Self-hosted instances run with one apex (api. + app.) * and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy diff --git a/packages/db/src/constants.ts b/packages/db/src/constants.ts index d1c55d85..891b0001 100644 --- a/packages/db/src/constants.ts +++ b/packages/db/src/constants.ts @@ -3,7 +3,7 @@ const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, PUBLIC_SERVER_URL } = process.e const DEFAULT_TRUSTED_ORIGINS = [ 'http://localhost:5173', 'https://*.classroomio.com', - 'https://*.classroomio.school', + 'https://*.myclassroomio.com', // Common OIDC IdP discovery origins (SSO registration fetches .well-known/openid-configuration) 'https://accounts.google.com', 'https://account.google.com', // common typo for Google diff --git a/packages/utils/src/constants/domains.ts b/packages/utils/src/constants/domains.ts index e3a6e3f9..5e325896 100644 --- a/packages/utils/src/constants/domains.ts +++ b/packages/utils/src/constants/domains.ts @@ -3,7 +3,7 @@ * The `apps/cio-tenant-router` Cloudflare Worker terminates traffic for this zone and forwards * to the dashboard service. Marketing apex (classroomio.com) is intentionally separate. */ -export const TENANT_ROOT_DOMAIN = 'classroomio.school'; +export const TENANT_ROOT_DOMAIN = 'myclassroomio.com'; /** The marketing / admin / api zone. */ export const BRAND_ROOT_DOMAIN = 'classroomio.com';