From 35e780b39de7fa723aa750a1d1acbc664ed63ef7 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 19 May 2026 10:24:18 +0100 Subject: [PATCH] feat: enhance CORS handling with public API support and session management --- apps/api/src/app.ts | 27 +++++++------- apps/api/src/middlewares/cors.ts | 36 +++++++++++++++++++ apps/api/src/routes/v1/index.ts | 6 +++- .../src/lib/features/app/init.svelte.ts | 5 +++ .../validation/organization/organization.ts | 8 ++++- 5 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/middlewares/cors.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c3acc678..4542f360 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,13 +1,12 @@ import 'dotenv/config'; import { API_SERVER_URL } from '@api/constants'; -import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins'; import { Hono } from '@api/utils/hono'; import { accountRouter } from '@api/routes/account'; import { auth } from '@cio/db/auth'; import { communityRouter } from '@api/routes/community'; -import { cors } from 'hono/cors'; +import { isPublicApiPath, sessionCors } from '@api/middlewares/cors'; import { courseRouter } from '@api/routes/course'; import { dashAnalyticsRouter } from '@api/routes/dash'; import { domainRouter } from '@api/routes/domain/domain'; @@ -42,19 +41,21 @@ export const app = new Hono() .use('*', logger()) .use('*', prettyJSON()) .use('*', secureHeaders()) - .use( - '*', - cors({ - origin: (origin) => resolveTrustedBrowserOriginForCors(origin), - allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'], - exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], - maxAge: 600, - credentials: true - }) - ) + .use('*', async (c, next) => { + if (isPublicApiPath(c.req.path)) return next(); + + return sessionCors(c, next); + }) .use('*', rateLimiter) .use('*', async (c, next) => { + if (isPublicApiPath(c.req.path)) { + c.set('user', null); + c.set('session', null); + c.set('orgRoles', {}); + + return next(); + } + let session: Awaited> | null = null; try { diff --git a/apps/api/src/middlewares/cors.ts b/apps/api/src/middlewares/cors.ts new file mode 100644 index 00000000..360d113a --- /dev/null +++ b/apps/api/src/middlewares/cors.ts @@ -0,0 +1,36 @@ +import { cors } from 'hono/cors'; +import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins'; + +const sharedCorsOptions = { + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'], + exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], + maxAge: 600 +}; + +/** + * Bearer-token routes meant to be called from any third-party origin (no cookies). + * `*` is safe here because `credentials` is off — the browser will reject a Set-Cookie roundtrip. + */ +export const publicApiCors = cors({ + ...sharedCorsOptions, + origin: '*', + credentials: false +}); + +/** + * Cookie/session routes (dashboard RPC, Better Auth, org dashboards). + * Allowed origins: configured `TRUSTED_ORIGINS`, `*.classroomio.com`, verified custom domains. + */ +export const sessionCors = cors({ + ...sharedCorsOptions, + origin: (origin) => resolveTrustedBrowserOriginForCors(origin), + credentials: true +}); + +/** Path prefix considered "public REST API" for CORS / session bypass. */ +export const PUBLIC_API_PATH_PREFIX = '/public-api/'; + +export function isPublicApiPath(path: string): boolean { + return path.startsWith(PUBLIC_API_PATH_PREFIX); +} diff --git a/apps/api/src/routes/v1/index.ts b/apps/api/src/routes/v1/index.ts index 9f06aa5a..14fafa13 100644 --- a/apps/api/src/routes/v1/index.ts +++ b/apps/api/src/routes/v1/index.ts @@ -1,5 +1,9 @@ import { Hono } from '@api/utils/hono'; +import { publicApiCors } from '@api/middlewares/cors'; import { v1AudienceRouter } from './audience'; import { v1CoursesRouter } from './courses'; -export const v1Router = new Hono().route('/audience', v1AudienceRouter).route('/courses', v1CoursesRouter); +export const v1Router = new Hono() + .use('*', publicApiCors) + .route('/audience', v1AudienceRouter) + .route('/courses', v1CoursesRouter); diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index bb0a6f7e..58f9f4d0 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -38,6 +38,11 @@ class AppInitApi extends BaseApi { async setupApp(locals: App.Locals, params: AppSetupParams): Promise { if (!locals.user) { + if (!params.isOrgSite) { + goto(resolve('/login', {})); + return false; + } + console.log('No user found in locals'); return; } diff --git a/packages/utils/src/validation/organization/organization.ts b/packages/utils/src/validation/organization/organization.ts index 3a75a6bf..fb63cdfa 100644 --- a/packages/utils/src/validation/organization/organization.ts +++ b/packages/utils/src/validation/organization/organization.ts @@ -5,7 +5,13 @@ import { blockedSubdomain } from '@cio/utils/constants'; export const ZGetOrganizations = z.object({ siteName: z.string().min(1).optional(), customDomain: z.string().min(1).optional(), - isCustomDomainVerified: z.boolean().optional() + /** + * Accepts a real boolean (server-side direct calls) or the URL-encoded `'true'`/`'false'` + * string Hono RPC produces when serializing query params. + */ + isCustomDomainVerified: z + .union([z.boolean(), z.literal('true').transform(() => true), z.literal('false').transform(() => false)]) + .optional() }); export type TGetOrganizations = z.infer;