From e97832e6d934228051ce229f8035aa65720fe1b2 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 20 May 2026 07:03:18 +0100 Subject: [PATCH] refactor: update API routing to use `/proxy` prefix for same-origin requests --- .../src/lib/utils/services/api/index.ts | 9 ++++--- .../src/lib/utils/services/auth/client.ts | 27 ++++++++++++------- apps/tenant-router/README.md | 8 +++++- apps/tenant-router/src/index.ts | 24 ++++++++++------- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 9320b113..189d7ed1 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -16,10 +16,11 @@ export const getRequestBaseUrl = () => { return process.env.PRIVATE_SERVER_URL || env.PUBLIC_SERVER_URL; } - // Browser: same-origin via the Cloudflare Worker /api proxy. Keeps auth - // cookies host-only on whichever domain the user is currently visiting - // (app.classroomio.com, .classroomio.school, or a BYOD domain). - return `${window.location.origin}/api`; + // Browser: same-origin via the Cloudflare Worker `/proxy` prefix. Keeps + // auth cookies host-only on whichever domain the user is currently + // visiting (app.classroomio.com, .classroomio.school, or BYOD). + // The Worker strips `/proxy` and forwards the remainder to the API. + return `${window.location.origin}/proxy`; }; function mergeAbortSignals(...signals: Array) { diff --git a/apps/dashboard/src/lib/utils/services/auth/client.ts b/apps/dashboard/src/lib/utils/services/auth/client.ts index 87f24ffc..c23c5c99 100644 --- a/apps/dashboard/src/lib/utils/services/auth/client.ts +++ b/apps/dashboard/src/lib/utils/services/auth/client.ts @@ -2,16 +2,25 @@ import { createAuthClient } from 'better-auth/svelte'; import { env } from '$env/dynamic/public'; import { ssoClient } from '@better-auth/sso/client'; -// Browser: same-origin `/api` via the Cloudflare Worker, so auth cookies -// stay host-only on whichever domain the user is currently on -// (app.classroomio.com, .classroomio.school, or a BYOD domain). -// SSR: `authClient`'s methods aren't called server-side — the dashboard uses -// `authServerClient` for that — but Better Auth's constructor still validates -// the baseURL with `new URL(...)`, so we hand it the public API URL as a -// placeholder. PRIVATE_SERVER_URL is deliberately not used here: it's the -// internal Render hostname, never reachable from a browser. +// Browser: same-origin via the Cloudflare Worker, so auth cookies stay +// host-only on whichever domain the user is on (app.classroomio.com, +// .classroomio.school, or a BYOD domain). The URL is +// `/proxy/api/auth`: +// - `/proxy` is the Worker's same-origin escape hatch — it strips +// that prefix before forwarding to the upstream API. +// - `/api/auth` is where Better Auth is mounted on the Hono API. +// Better Auth's client treats any baseURL with a path as the auth root +// verbatim (it won't auto-append `/api/auth`), so we encode the full +// downstream path here. +// +// SSR: `authClient` methods are never invoked server-side (the dashboard +// uses `authServerClient` for that), but Better Auth's constructor still +// validates baseURL with `new URL(...)`, so we hand it the public API +// URL as an absolute-URL placeholder. const baseURL = - typeof window !== 'undefined' ? `${window.location.origin}/api` : env.PUBLIC_SERVER_URL || 'http://localhost:3002'; + typeof window !== 'undefined' + ? `${window.location.origin}/proxy/api/auth` + : env.PUBLIC_SERVER_URL || 'http://localhost:3002'; export const authClient = createAuthClient({ baseURL, diff --git a/apps/tenant-router/README.md b/apps/tenant-router/README.md index 82378cb0..73f9b6ed 100644 --- a/apps/tenant-router/README.md +++ b/apps/tenant-router/README.md @@ -11,9 +11,15 @@ The Worker forwards each request to one of two Render services based on path: | Path | Upstream | |---|---| -| `/api/*` | API service (`/api` prefix stripped) | +| `/proxy/*` | API service (`/proxy` prefix stripped, rest forwarded as-is) | | anything else | Dashboard service | +The `/proxy` prefix is the same-origin escape hatch the dashboard's +browser code uses so its auth cookies stay host-only while still reaching +the API. Example: a browser request to +`https://app.classroomio.com/proxy/api/auth/sign-in/email` becomes +`/api/auth/sign-in/email` on the API service (where Better Auth lives). + 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. diff --git a/apps/tenant-router/src/index.ts b/apps/tenant-router/src/index.ts index 18877631..2f5b6cd0 100644 --- a/apps/tenant-router/src/index.ts +++ b/apps/tenant-router/src/index.ts @@ -8,13 +8,19 @@ * - /* — 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 + * - /proxy/* → API service (strips the /proxy prefix, forwards the rest) + * - 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. + * The `/proxy` prefix is the same-origin escape hatch used by the + * dashboard's browser code so its auth cookies stay host-only while still + * reaching the API. Any path after `/proxy` is the real API path on the + * Hono server — e.g. `/proxy/api/auth/sign-in/email` strips to + * `/api/auth/sign-in/email`, which is where Better Auth is mounted. + * + * The Worker 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. */ interface Env { @@ -23,7 +29,7 @@ interface Env { APEX_REDIRECT_TARGET: string; } -const API_PREFIX = '/api'; +const PROXY_PREFIX = '/proxy'; export default { async fetch(request: Request, env: Env): Promise { @@ -38,9 +44,9 @@ export default { return Response.redirect(target.toString(), 301); } - const isApiCall = url.pathname === API_PREFIX || url.pathname.startsWith(`${API_PREFIX}/`); + const isApiCall = url.pathname === PROXY_PREFIX || url.pathname.startsWith(`${PROXY_PREFIX}/`); const upstreamHost = isApiCall ? env.API_UPSTREAM_HOST : env.DASHBOARD_UPSTREAM_HOST; - const upstreamPath = isApiCall ? url.pathname.slice(API_PREFIX.length) || '/' : url.pathname; + const upstreamPath = isApiCall ? url.pathname.slice(PROXY_PREFIX.length) || '/' : url.pathname; const upstreamUrl = new URL(request.url); upstreamUrl.protocol = 'https:';