fix: encode body of translation updating, reduce p margin in prose
This commit is contained in:
@@ -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":"<base64 of inner JSON>"}` — 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":"<base64 of inner JSON>"} → decode
|
|
||||||
// - old dashboard: <inner JSON directly> → 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();
|
|
||||||
};
|
|
||||||
@@ -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":"<base64 of inner JSON>"}` 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":"<base64 of inner JSON>"} → decode
|
||||||
|
// - old dashboard: <inner JSON directly> → 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();
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { authMiddleware } from '@api/middlewares/auth';
|
|||||||
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
import { orgMemberMiddleware } from '@api/middlewares/org-member';
|
||||||
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
|
||||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
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 { handleError, AppError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
|
import { streamText, stepCountIs, convertToModelMessages } from 'ai';
|
||||||
@@ -690,7 +690,7 @@ const agentCoreRouter = new Hono()
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const agentRouter = new Hono()
|
export const agentRouter = new Hono()
|
||||||
.use('*', agentContentTypeRewrite)
|
.use('*', b64EnvelopeRewrite)
|
||||||
.route('/', agentCoreRouter)
|
.route('/', agentCoreRouter)
|
||||||
.route('/history', agentHistoryRouter)
|
.route('/history', agentHistoryRouter)
|
||||||
.route('/runs', agentRunsRouter);
|
.route('/runs', agentRunsRouter);
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import {
|
|||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import type { TLocale } from '@db/types';
|
import type { TLocale } from '@db/types';
|
||||||
|
import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const lessonLanguageRouter = new Hono()
|
export const lessonLanguageRouter = new Hono()
|
||||||
|
.use('*', b64EnvelopeRewrite)
|
||||||
/**
|
/**
|
||||||
* GET /course/:courseId/lesson/:lessonId/language
|
* GET /course/:courseId/lesson/:lessonId/language
|
||||||
* Gets all language translations for a lesson
|
* Gets all language translations for a lesson
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ html .dark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
@apply mb-4;
|
@apply mb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { currentOrg } from '$lib/utils/store/org';
|
|||||||
import type { Cookies } from '@sveltejs/kit';
|
import type { Cookies } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { getCioCookieString } from '$lib/utils/functions/cookies';
|
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 = () => {
|
export const getRequestBaseUrl = () => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -32,11 +32,11 @@ export const getRequestBaseUrl = () => {
|
|||||||
return `${window.location.origin}/proxy`;
|
return `${window.location.origin}/proxy`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAgentRequest(input: RequestInfo | URL): boolean {
|
function requiresB64Envelope(input: RequestInfo | URL): boolean {
|
||||||
try {
|
try {
|
||||||
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
const pathname = new URL(raw, 'http://placeholder').pathname;
|
const pathname = new URL(raw, 'http://placeholder').pathname;
|
||||||
return pathname.includes('/agent/');
|
return pathname.includes('/agent/') || /\/lesson\/[^/]+\/language/.test(pathname);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -146,14 +146,14 @@ class ApiClient {
|
|||||||
headers.delete('Content-Type');
|
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
|
// 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
|
// 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.
|
// middleware decodes the envelope before any route handler sees it.
|
||||||
const inner = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody);
|
const inner = typeof requestBody === 'string' ? requestBody : JSON.stringify(requestBody);
|
||||||
requestBody = JSON.stringify({ b64: toBase64Utf8(inner) });
|
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
|
// Create abort controller for timeout
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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';
|
||||||
@@ -4,4 +4,4 @@ export * from './content';
|
|||||||
export * from './domains';
|
export * from './domains';
|
||||||
export * from './embeds';
|
export * from './embeds';
|
||||||
export * from './error-codes';
|
export * from './error-codes';
|
||||||
export * from './agent';
|
export * from './b64-envelope';
|
||||||
|
|||||||
@@ -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 <user-uuid> <user-email> [redirect-path] [ttl]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* node scripts/impersonate.mjs 3f2a... [email protected]
|
||||||
|
* node scripts/impersonate.mjs 3f2a... [email protected] /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 <user-uuid> <user-email> [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');
|
||||||
Reference in New Issue
Block a user