chore: auto enroll, custom domain improvement, course pricing style improv.

This commit is contained in:
Rotimi Best
2026-05-20 21:58:42 +01:00
parent 38c0c0b4f0
commit 29b02c0011
21 changed files with 288 additions and 239 deletions
+24 -24
View File
@@ -83,17 +83,17 @@ The same flow works for `app.classroomio.com` (the proxy-skip branch in `oAuthPr
## Pieces and where they live
| Concern | File | Notes |
|---|---|---|
| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. |
| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. |
| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. |
| Redis storage helpers | `apps/api/src/utils/redis/oauth-handoff.ts` | `storeHandoffPayload` (2-minute TTL) + `consumeHandoffPayload` (single-use, GET-and-DEL). |
| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. |
| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. |
| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. |
| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. |
| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. |
| Concern | File | Notes |
| --------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. |
| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. |
| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. |
| Redis storage helpers | `apps/api/src/utils/redis/oauth-handoff.ts` | `storeHandoffPayload` (2-minute TTL) + `consumeHandoffPayload` (single-use, GET-and-DEL). |
| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. |
| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. |
| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. |
| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. |
| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. |
## Why the Redis handoff exists
@@ -126,22 +126,22 @@ If Google's consent screen warns users that the app is requesting "sensitive" or
On the **API** Render service:
| Var | Value | Why |
|---|---|---|
| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. |
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. |
| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. |
| Var | Value | Why |
| ------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. |
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. |
| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. |
On the **dashboard** Render service:
| Var | Value | Why |
|---|---|---|
| `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. |
| `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. |
| Var | Value | Why |
| -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
## Verifying the flow
+2 -6
View File
@@ -98,13 +98,9 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
databaseHooks: {
user: {
create: {
after: async (user, ctx) => {
after: async (user) => {
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
const request =
(ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ??
(ctx as { request?: Request } | undefined)?.request;
await createProfileHook(user, request);
await createProfileHook(user);
}
},
update: {
+1 -6
View File
@@ -6,7 +6,6 @@ import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { getOrganizationCount } from '@db/queries/organization';
import { ssoProvisioningHook } from './sso-provisioning';
import { tenantProvisioningHook } from './tenant-provisioning';
/**
* True if the user has any non-credential account (OAuth/SSO). Those providers verify email.
@@ -37,7 +36,7 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
*
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
*/
export const createProfileHook = async (user: User, request?: Request) => {
export const createProfileHook = async (user: User, _request?: Request) => {
console.log('[auth] createProfileHook: running', { userId: user.id });
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
@@ -84,10 +83,6 @@ export const createProfileHook = async (user: User, request?: Request) => {
// Run SSO provisioning hook for JIT org membership (email-domain match)
await ssoProvisioningHook(user);
// Run tenant provisioning hook for signups on a tenant subdomain
// (signupGuard has already enforced disableSignup / inviteOnly)
await tenantProvisioningHook(user, request);
} catch (error) {
console.error('Error creating profile for user:', error);
}
@@ -1,134 +0,0 @@
import * as schema from '@db/schema';
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants';
import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization';
import { User } from 'better-auth';
import { db } from '@db/drizzle';
import { ensureOrgMembership } from './sso-provisioning';
import { eq } from 'drizzle-orm';
/**
* Subdomains under the brand/tenant root that are NOT a tenant org.
* Reuses the dashboard's `blockedSubdomain` list so the two stay in sync.
*/
const RESERVED_SUBDOMAINS = new Set<string>(blockedSubdomain);
/**
* Resolve the org a signup happened on, given the inbound request's host.
*
* Resolution order:
* 1. Subdomain under TENANT_ROOT_DOMAIN (e.g. ciodevs.classroomio.school) → siteName lookup
* 2. Subdomain under BRAND_ROOT_DOMAIN (e.g. ciodevs.classroomio.com) → siteName lookup
* 3. Anything else (e.g. learn.acme.com) → verified-custom-domain lookup
*
* Returns null for the apex, reserved subdomains (app/api/www/…), and unverified custom domains.
*/
async function resolveOrgIdFromHost(host: string): Promise<string | null> {
const hostname = host.split(':')[0]?.toLowerCase();
if (!hostname) return null;
const tenantSuffix = `.${TENANT_ROOT_DOMAIN}`;
const brandSuffix = `.${BRAND_ROOT_DOMAIN}`;
if (hostname.endsWith(tenantSuffix) || hostname.endsWith(brandSuffix)) {
const suffix = hostname.endsWith(tenantSuffix) ? tenantSuffix : brandSuffix;
const subdomain = hostname.slice(0, -suffix.length);
// Apex (no subdomain) or multi-level subdomain we don't recognise
if (!subdomain || subdomain.includes('.')) return null;
if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
const org = await getOrganizationBySiteName(subdomain);
return org?.id ?? null;
}
const org = await getOrganizationByCustomDomain(hostname);
return org?.id ?? null;
}
/**
* Auto-enroll a signup into the org they signed up on.
*
* Runs after `ssoProvisioningHook` so SSO-domain matches (which carry policy-driven
* role mapping) win. `ensureOrgMembership` is idempotent and invite-aware, so if SSO
* already inserted a row this is a no-op.
*
* The `signupGuard` middleware (apps/api/src/middlewares/signup-guard.ts) has already
* rejected `disableSignup` orgs and `inviteOnly` orgs without a valid invite by the
* time this runs — so any signup that reaches the hook is allowed to join.
*
* Org resolution order:
* 1. `cio-org-id` request header (set by the dashboard on email signup)
* 2. OAuth `state` → verification row → `callbackURL` host. Used for Google
* and other OAuth/SSO flows where the callback lands on `api.<root>`
* directly with no tenant host header. The dashboard always sets
* `callbackURL` to the tenant origin, so its host is the trusted signal.
* 3. `x-forwarded-host` / `host`: tenant subdomain, brand subdomain, or
* verified custom domain. Fallback for non-OAuth flows.
*/
async function resolveHostFromOAuthState(request: Request): Promise<string | null> {
const requestUrl = new URL(request.url);
const state = requestUrl.searchParams.get('state');
console.log('[auth] tenantProvisioningHook: oauth state', state);
if (!state) return null;
try {
const [row] = await db
.select({ value: schema.verification.value })
.from(schema.verification)
.where(eq(schema.verification.identifier, state))
.limit(1);
console.log('[auth] tenantProvisioningHook: verification row?', !!row);
if (!row?.value) return null;
const stateData = JSON.parse(row.value) as { callbackURL?: string };
console.log('[auth] tenantProvisioningHook: state.callbackURL', stateData.callbackURL);
if (!stateData.callbackURL) return null;
const host = new URL(stateData.callbackURL).host;
console.log('[auth] tenantProvisioningHook: host from state.callbackURL', host);
return host;
} catch (error) {
console.error('[auth] tenantProvisioningHook: failed to resolve OAuth state', error);
return null;
}
}
export const tenantProvisioningHook = async (user: User, request?: Request) => {
if (!user.email || !request) return;
console.log('[auth] tenantProvisioningHook: running', { userId: user.id });
const headerOrgId = request.headers.get('cio-org-id');
console.log('[auth] tenantProvisioningHook: headerOrgId', headerOrgId);
if (headerOrgId) {
console.log('[auth] tenantProvisioningHook: enrolling via header', { userId: user.id, orgId: headerOrgId });
await ensureOrgMembership(user.id, user.email, headerOrgId);
return;
}
// Prefer the OAuth state's callbackURL host (set by the dashboard on the
// tenant origin) before falling back to forwarded/host headers, which on a
// direct Google → api.classroomio.com callback won't reflect the tenant.
const stateHost = await resolveHostFromOAuthState(request);
const fwdHost = request.headers.get('x-forwarded-host');
const rawHost = request.headers.get('host');
console.log('[auth] tenantProvisioningHook: candidate hosts', { stateHost, fwdHost, rawHost });
const host = stateHost ?? fwdHost ?? rawHost;
if (!host) {
console.log('[auth] tenantProvisioningHook: no host candidate, skipping enrollment');
return;
}
const orgId = await resolveOrgIdFromHost(host);
console.log('[auth] tenantProvisioningHook: resolved orgId from host', { host, orgId });
if (!orgId) {
console.log('[auth] tenantProvisioningHook: no org for host, skipping enrollment');
return;
}
console.log('[auth] tenantProvisioningHook: enrolling', { userId: user.id, orgId });
await ensureOrgMembership(user.id, user.email, orgId);
};