chore: move lms to myclassroomio.com and use approximated for custom
domain
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T> {
|
||||
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<T>(path: string, init: RequestInit = {}): Promise<T | null> {
|
||||
ensureApproximatedConfig();
|
||||
|
||||
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<T>;
|
||||
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<T>(path: string, init: RequestInit = {}): Promi
|
||||
);
|
||||
}
|
||||
|
||||
return payload.result;
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function dedupeDnsRecords(records: DomainDnsRecord[]) {
|
||||
const seen = new Set<string>();
|
||||
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<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
|
||||
async function getVhost(hostname: string): Promise<ApproximatedVhost | null> {
|
||||
return approximatedRequest<ApproximatedVhost>(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
async function createCustomHostname(hostname: string) {
|
||||
return cloudflareRequest<CloudflareCustomHostname>('', {
|
||||
async function createVhost(hostname: string): Promise<ApproximatedVhost> {
|
||||
const created = await approximatedRequest<ApproximatedVhost>('/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<CloudflareCustomHostname>(`/${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<DomainSetupResult> {
|
||||
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<DomainSetupResult> {
|
||||
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<DomainSetupResult> {
|
||||
const existing = await getCustomHostnameByDomain(hostname);
|
||||
|
||||
if (existing) {
|
||||
await cloudflareRequest<null>(`/${existing.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
await approximatedRequest<null>(`/vhosts/by/incoming/${encodeURIComponent(hostname)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
return {
|
||||
hostname,
|
||||
@@ -313,7 +226,8 @@ export async function removeDomain(hostname: string): Promise<DomainSetupResult>
|
||||
verified: false,
|
||||
reconnectRequired: false,
|
||||
message: getStatusMessage('removed'),
|
||||
provider: 'cloudflare',
|
||||
dnsRecords: []
|
||||
provider: 'approximated',
|
||||
dnsRecords: [],
|
||||
validationErrors: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 `<org>.classroomio.school` or a verified
|
||||
* aren't yet a member of (free-tier `<org>.myclassroomio.com` or a verified
|
||||
* BYOD domain).
|
||||
*
|
||||
* Idempotent: existing members get an `alreadyMember: true` no-op so invited
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
* - <BYOD>/* — 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;
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
This doc explains how Google sign-in works for ClassroomIO across three different host families:
|
||||
|
||||
- `app.classroomio.com` — admin dashboard
|
||||
- `<orgSiteName>.classroomio.school` — free-tier tenant sites
|
||||
- `<orgSiteName>.myclassroomio.com` — free-tier tenant sites
|
||||
- `<customer-owned>.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. |
|
||||
|
||||
|
||||
@@ -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 (<org>.classroomio.school or a BYOD domain).
|
||||
* user signed in from (<org>.myclassroomio.com or a BYOD domain).
|
||||
*
|
||||
* Self-hosted instances run with one apex (api.<domain> + app.<domain>)
|
||||
* and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user