diff --git a/apps/api/src/middlewares/agent-content-type.ts b/apps/api/src/middlewares/agent-content-type.ts deleted file mode 100644 index 5db76ef4..00000000 --- a/apps/api/src/middlewares/agent-content-type.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Context, Next } from 'hono'; -import { AGENT_CONTENT_TYPE } from '@cio/utils/constants'; - -/** - * The dashboard wraps `/agent/*` write bodies in a base64 envelope — - * `{"b64":""}` — and tags them with - * `application/x-cio-agent`. Render fronts our service with its own Cloudflare, - * whose OWASP managed rules body-inspect every JSON request and 403 anything - * that looks like SQL injection / XSS / command injection / markdown - * jailbreak — patterns that AI-generated lesson content, fetched docs, and - * the conversation history naturally produce. CF cannot pattern-match - * base64, so the envelope passes inspection. This middleware unwraps the - * envelope and restores a plain `application/json` body before zValidator - * (which gates on a `/application\/.+?\+?json/` regex) parses it, so route - * handlers downstream see the request unchanged. - */ -export const agentContentTypeRewrite = async (c: Context, next: Next) => { - const ct = c.req.header('content-type')?.toLowerCase() ?? ''; - if (!ct.startsWith(AGENT_CONTENT_TYPE)) { - return next(); - } - - const envelope = await c.req.raw.text(); - let innerJson: string; - try { - const parsed = JSON.parse(envelope) as { b64?: string }; - // Accept either shape during the deploy window: - // - new dashboard: {"b64":""} → decode - // - old dashboard: → pass through - // The old path still risks a WAF 403 but at least keeps requests flowing - // until the dashboard rollout catches up. - innerJson = typeof parsed.b64 === 'string' ? Buffer.from(parsed.b64, 'base64').toString('utf-8') : envelope; - } catch { - return c.json({ success: false, error: 'INVALID_AGENT_ENVELOPE', code: 'INVALID_AGENT_ENVELOPE' }, 400); - } - - const headers = new Headers(c.req.raw.headers); - headers.set('content-type', 'application/json'); - c.req.raw = new Request(c.req.raw.url, { - method: c.req.raw.method, - headers, - body: innerJson - }); - - return next(); -}; diff --git a/apps/api/src/middlewares/b64-envelope.ts b/apps/api/src/middlewares/b64-envelope.ts new file mode 100644 index 00000000..8bfb3f3b --- /dev/null +++ b/apps/api/src/middlewares/b64-envelope.ts @@ -0,0 +1,41 @@ +import type { Context, Next } from 'hono'; +import { CIO_ENVELOPE_CONTENT_TYPE } from '@cio/utils/constants'; + +/** + * Unwraps the base64 envelope used on write endpoints whose bodies contain + * HTML or AI-generated content. The dashboard wraps such bodies as + * `{"b64":""}` with `Content-Type: application/x-cio-agent` + * so Render's Cloudflare WAF (OWASP managed rules) cannot body-inspect the + * payload. This middleware decodes the envelope and restores a plain + * `application/json` body before zValidator runs. + */ +export const b64EnvelopeRewrite = async (c: Context, next: Next) => { + const ct = c.req.header('content-type')?.toLowerCase() ?? ''; + if (!ct.startsWith(CIO_ENVELOPE_CONTENT_TYPE)) { + return next(); + } + + const envelope = await c.req.raw.text(); + let innerJson: string; + try { + const parsed = JSON.parse(envelope) as { b64?: string }; + // Accept either shape during the deploy window: + // - new dashboard: {"b64":""} → decode + // - old dashboard: → pass through + // The old path still risks a WAF 403 but at least keeps requests flowing + // until the dashboard rollout catches up. + innerJson = typeof parsed.b64 === 'string' ? Buffer.from(parsed.b64, 'base64').toString('utf-8') : envelope; + } catch { + return c.json({ success: false, error: 'INVALID_B64_ENVELOPE', code: 'INVALID_B64_ENVELOPE' }, 400); + } + + const headers = new Headers(c.req.raw.headers); + headers.set('content-type', 'application/json'); + c.req.raw = new Request(c.req.raw.url, { + method: c.req.raw.method, + headers, + body: innerJson + }); + + return next(); +}; diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index 25775021..341c4c33 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -4,7 +4,7 @@ import { authMiddleware } from '@api/middlewares/auth'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; -import { agentContentTypeRewrite } from '@api/middlewares/agent-content-type'; +import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope'; import { handleError, AppError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; import { streamText, stepCountIs, convertToModelMessages } from 'ai'; @@ -690,7 +690,7 @@ const agentCoreRouter = new Hono() }); export const agentRouter = new Hono() - .use('*', agentContentTypeRewrite) + .use('*', b64EnvelopeRewrite) .route('/', agentCoreRouter) .route('/history', agentHistoryRouter) .route('/runs', agentRunsRouter); diff --git a/apps/api/src/routes/course/lesson-language.ts b/apps/api/src/routes/course/lesson-language.ts index 3cb644bb..248bdf01 100644 --- a/apps/api/src/routes/course/lesson-language.ts +++ b/apps/api/src/routes/course/lesson-language.ts @@ -13,12 +13,14 @@ import { import { Hono } from '@api/utils/hono'; import type { TLocale } from '@db/types'; +import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope'; import { authMiddleware } from '@api/middlewares/auth'; import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { handleError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; export const lessonLanguageRouter = new Hono() + .use('*', b64EnvelopeRewrite) /** * GET /course/:courseId/lesson/:lessonId/language * Gets all language translations for a lesson diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index dda9122e..eed3ee62 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -118,7 +118,7 @@ html .dark { } .prose p { - @apply mb-4; + @apply mb-1; } .prose a { diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index c1001d73..aa48150a 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -9,7 +9,7 @@ import { currentOrg } from '$lib/utils/store/org'; import type { Cookies } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; import { getCioCookieString } from '$lib/utils/functions/cookies'; -import { AGENT_CONTENT_TYPE } from '@cio/utils/constants'; +import { CIO_ENVELOPE_CONTENT_TYPE } from '@cio/utils/constants'; export const getRequestBaseUrl = () => { if (typeof window === 'undefined') { @@ -32,11 +32,11 @@ export const getRequestBaseUrl = () => { return `${window.location.origin}/proxy`; }; -function isAgentRequest(input: RequestInfo | URL): boolean { +function requiresB64Envelope(input: RequestInfo | URL): boolean { try { const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const pathname = new URL(raw, 'http://placeholder').pathname; - return pathname.includes('/agent/'); + return pathname.includes('/agent/') || /\/lesson\/[^/]+\/language/.test(pathname); } catch { return false; } @@ -146,14 +146,14 @@ class ApiClient { headers.delete('Content-Type'); } - if (requestBody && !isFormData && isAgentRequest(input)) { + if (requestBody && !isFormData && requiresB64Envelope(input)) { // Wrap the inner JSON in a base64 envelope so Render's Cloudflare WAF // can't body-inspect the content (AI-generated HTML, code blocks, curl - // examples, etc. trip OWASP rules). The API's `agentContentTypeRewrite` + // examples, etc. trip OWASP rules). The API's `b64EnvelopeRewrite` // middleware decodes the envelope before any route handler sees it. const inner = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody); requestBody = JSON.stringify({ b64: toBase64Utf8(inner) }); - headers.set('Content-Type', AGENT_CONTENT_TYPE); + headers.set('Content-Type', CIO_ENVELOPE_CONTENT_TYPE); } // Create abort controller for timeout diff --git a/packages/utils/src/constants/agent.ts b/packages/utils/src/constants/agent.ts deleted file mode 100644 index 8effeb7f..00000000 --- a/packages/utils/src/constants/agent.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Custom request `Content-Type` for `/agent/*` write endpoints. - * - * Render fronts every service with Cloudflare, which runs OWASP Managed Rules - * on bodies whose content-type substring-matches `application/json` and rejects - * AI chat payloads that contain SQL-injection-shaped strings (e.g. user - * messages discussing `SELECT … WHERE … OR 1=1`). A vendor-specific type - * falls through inspection. A small server middleware rewrites it back to - * `application/json` so `zValidator` parses the body unchanged. - */ -export const AGENT_CONTENT_TYPE = 'application/x-cio-agent'; diff --git a/packages/utils/src/constants/b64-envelope.ts b/packages/utils/src/constants/b64-envelope.ts new file mode 100644 index 00000000..67e35d82 --- /dev/null +++ b/packages/utils/src/constants/b64-envelope.ts @@ -0,0 +1,12 @@ +/** + * Custom request `Content-Type` for write endpoints whose bodies contain + * HTML or AI-generated content. + * + * Render fronts every service with Cloudflare, which runs OWASP Managed Rules + * on bodies whose content-type substring-matches `application/json` and rejects + * payloads that contain SQL-injection or XSS-shaped strings. A vendor-specific + * type falls through inspection. A server middleware unwraps the base64 + * envelope and rewrites the content-type back to `application/json` so + * `zValidator` parses the body unchanged. + */ +export const CIO_ENVELOPE_CONTENT_TYPE = 'application/x-cio-agent'; diff --git a/packages/utils/src/constants/index.ts b/packages/utils/src/constants/index.ts index e1caf2e1..47108867 100644 --- a/packages/utils/src/constants/index.ts +++ b/packages/utils/src/constants/index.ts @@ -4,4 +4,4 @@ export * from './content'; export * from './domains'; export * from './embeds'; export * from './error-codes'; -export * from './agent'; +export * from './b64-envelope'; diff --git a/scripts/impersonate.mjs b/scripts/impersonate.mjs new file mode 100644 index 00000000..b17af158 --- /dev/null +++ b/scripts/impersonate.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Mint a login-link token and print a URL that logs you in as a given user. + * + * For support/debugging only. The session it creates is stamped + * `impersonatedBy: 'login-link'` so it's auditable. + * + * Config (env or edit the constants below): + * BETTER_AUTH_SECRET HS256 signing secret for the target environment + * APP_URL Base URL of the app, e.g. https://app.example.com + * + * Usage: + * BETTER_AUTH_SECRET=... APP_URL=https://app.example.com \ + * node scripts/impersonate.mjs [redirect-path] [ttl] + * + * Examples: + * node scripts/impersonate.mjs 3f2a... user@acme.com + * node scripts/impersonate.mjs 3f2a... user@acme.com /courses 30m + */ +import { SignJWT } from 'jose'; + +// Load a .env file if present. Override the path with ENV_FILE=... if needed. +// Existing process.env values are NOT overwritten by the file. +try { + process.loadEnvFile(process.env.ENV_FILE ?? new URL('../.env', import.meta.url)); +} catch { + // no .env found — fall back to real env vars / the constants below +} + +// --- config (env wins; otherwise fill these in) ----------------------------- +const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? ''; +const APP_URL = process.env.APP_URL ?? 'https://app.classroomio.com'; +// ---------------------------------------------------------------------------- + +const [userId, email, redirect = '/', ttl = '10m'] = process.argv.slice(2); + +function fail(message) { + console.error(`\n ✗ ${message}\n`); + process.exit(1); +} + +if (!BETTER_AUTH_SECRET) fail('Set BETTER_AUTH_SECRET (env var or the constant in this file).'); +if (!APP_URL) fail('Set APP_URL (env var or the constant in this file), e.g. https://app.example.com'); +if (!userId || !email) { + fail('Usage: node scripts/impersonate.mjs [redirect-path] [ttl]'); +} +if (!redirect.startsWith('/')) fail('redirect-path must start with "/" (same-site only).'); + +const secret = new TextEncoder().encode(BETTER_AUTH_SECRET.trim()); + +const token = await new SignJWT({ + sub: userId, + email: email.toLowerCase().trim(), + type: 'login-link' +}) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(ttl) + .sign(secret); + +const base = APP_URL.replace(/\/+$/, ''); +const url = `${base}/api/auth/login-link?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirect)}`; + +console.log(`\n Login as: ${email} (${userId})`); +console.log(` Expires in: ${ttl}\n`); +console.log(url); +console.log('\n Open this URL in a fresh/incognito window to start the session.\n');