diff --git a/.env.example b/.env.example index c9d1877..f65192f 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ DATABASE_URL=file:./data/slide-maker.db SESSION_SECRET=change-me-to-random-string # Email — pick one provider: ses or smtp -# Set EMAIL_PROVIDER=ses to use Amazon SES (uses AWS credentials from Bedrock config) +# Set EMAIL_PROVIDER=ses to use Amazon SES (uses AWS credentials for email only) EMAIL_PROVIDER=ses SES_FROM_EMAIL=ailab@gc.cuny.edu # SES_REGION=us-east-1 # defaults to AWS_REGION if not set @@ -20,25 +20,15 @@ SMTP_USER= SMTP_PASS= SMTP_FROM=noreply@ailab.gc.cuny.edu -# AI Providers -ANTHROPIC_API_KEY= -OPENROUTER_API_KEY= -# Optional: restrict provider at runtime (also supports CLI flag --provider) -# Valid values: bedrock | anthropic | openrouter -AI_PROVIDER= - -# AWS Bedrock (optional) -# Use either long-lived keys or assume-role via your environment. The SDK -# resolves credentials automatically; set region to enable Bedrock models. -AWS_REGION= -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# AWS_SESSION_TOKEN= - -# Admin-only model toggles (optional) -# If set, these models appear only for admin users. -BEDROCK_SONNET_46_MODEL_ID= -ANTHROPIC_SONNET_46_MODEL_ID= +# Institutional identity: public JWKS JSON snapshot and exact issuer URL +CAIL_IDENTITY_JWKS= +CAIL_IDENTITY_ISSUER=https://tools.ailab.gc.cuny.edu/cail-sso +# Gateway base URL is the origin, distinct from the identity issuer /cail-sso path. +# It must be a reviewed reachable receiver; never provider credentials. +CAIL_GATEWAY_URL=https://tools.ailab.gc.cuny.edu +# Private readiness probe; random operator-held token and exact deployed commit. +READINESS_TOKEN= +RELEASE_SHA= # Web Search TAVILY_API_KEY= @@ -50,3 +40,4 @@ PEXELS_API_KEY= # Frontend PUBLIC_API_URL=http://localhost:3001 PUBLIC_URL=http://localhost:5173 +# Production: PUBLIC_URL=https://tools.ailab.gc.cuny.edu/slide-maker diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76e3d4a..bdc8f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + packages: read concurrency: group: slide-maker-ci-${{ github.workflow }}-${{ github.ref }} @@ -34,12 +35,25 @@ jobs: with: node-version: 20.x cache: pnpm + registry-url: https://npm.pkg.github.com + scope: "@cuny-ai-lab" - name: Install dependencies run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check frontend types + run: pnpm --filter @slide-maker/web check - name: Build workspaces run: pnpm build - name: Run tests run: pnpm test + + - name: Install browser for institutional-path acceptance + run: pnpm exec playwright install --with-deps chromium + + - name: Verify mounted sign-in, persisted editing, model refusal and export + run: pnpm exec playwright test --config e2e/fleet.config.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..51f4345 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@cuny-ai-lab:registry=https://npm.pkg.github.com diff --git a/CLAUDE.md b/CLAUDE.md index 01ccdd2..5b3076c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Check `TODO.md` for the current task list when planning work, but default to the operator's instructions, especially if they involve birds. +## Institutional integration authority + +The current receiver contract and release proposal are in [docs/fleet-integration.md](docs/fleet-integration.md). They supersede the historical direct-provider, local-token-cap, password-production-auth and manual-deployment descriptions below. Model calls now use Gateway; production identity maps verified canonical subjects to unchanged local IDs. Do not deploy or activate a mount from historical staging instructions. + ## What This Is A chat-driven slide builder for the CUNY AI Lab. Users create presentation decks through AI conversation + direct on-canvas editing. Three-panel UI: chat + outline (left), canvas (center), resources (right). Produces HTML slide decks matching the CUNY AI Lab's actual deck framework (section-based navigation, step reveals, carousel sync). diff --git a/README.md b/README.md index 186e1c1..8deb62d 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ Open http://localhost:5173, log in with a seeded admin account, create a deck, a ## Prerequisites -- Node.js 18+ +- Node.js 20+ - [pnpm](https://pnpm.io/) 9.x (`corepack enable && corepack prepare pnpm@9.15.0 --activate`) ## Setup 1. **Install dependencies** + The pinned CAIL packages require authorized GitHub Packages read access. The checked-in `.npmrc` contains registry resolution only; supply credentials through your user configuration or CI environment. + ```bash pnpm install ``` @@ -35,17 +37,7 @@ Open http://localhost:5173, log in with a seeded admin account, create a deck, a ln -s ../../.env apps/api/.env # required — API loads env from its own CWD ``` - At minimum, set `SESSION_SECRET` and one AI provider: - - | Provider | Required env vars | - |----------|-------------------| - | OpenRouter | `OPENROUTER_API_KEY` | - | Anthropic | `ANTHROPIC_API_KEY` | - | AWS Bedrock | `AWS_REGION` + valid AWS credentials | - - Optional: - - `TAVILY_API_KEY` and/or `BRAVE_API_KEY` for web search (Tavily preferred) - - `PEXELS_API_KEY` for openly-licensed image search + Configure institutional identity and Gateway as described in [the receiver guide](docs/fleet-integration.md). Local password sessions are development-only; production requires signed institutional identity and an operator-verified local account mapping. Provider keys are not used by Slide Maker. 3. **Initialize the database** @@ -67,25 +59,9 @@ Starts both services via Turborepo: | Web (SvelteKit) | http://localhost:5173 | | API (Hono) | http://localhost:3001 | -### Provider selection +### Models and identity -Run with a specific AI provider: - -```bash -pnpm dev:anthropic # Anthropic SDK -pnpm dev:bedrock # AWS Bedrock -pnpm dev:openrouter # OpenRouter -``` - -Or set `AI_PROVIDER=bedrock|anthropic|openrouter` in your `.env`. - -### Available models - -| Provider | Models | -|----------|--------| -| Anthropic | Claude Sonnet 4, Haiku 4.5 (Sonnet 4.6 admin-only, requires env var) | -| Bedrock | Haiku 4.5, Sonnet 4.6 | -| OpenRouter | Kimi K2.5, GLM 5, Gemini 3.1 Flash, Qwen 3.5 Flash | +Chat and planning use the CAIL Gateway catalog and verified Gateway credentials. Gateway owns quota admission and accounting. Before serving an existing database with this version, run the additive identity migration and verify account mappings; see [the operator and release guide](docs/fleet-integration.md). ## Features @@ -152,11 +128,11 @@ Exports a self-contained ZIP with: ### Auth -Email/password with `*.cuny.edu` domain gating. Registration, email verification, admin approval. Lucia v3 session cookies. Rate-limited login (5/15min) and registration (3/hr). +Production uses verified institutional JWTs mapped explicitly to existing local user IDs. Product roles and sharing remain local. Development retains Lucia password sessions. ### Admin dashboard -User management, role assignment, token usage tracking with monthly charts and per-user caps (default 1M tokens, annual reset). +User management and local product role assignment. Gateway owns current model quota; existing local usage records are historical. ## Project structure @@ -188,10 +164,12 @@ npx vitest --watch # watch mode ## Deployment -Staging at `tools.cuny.qzz.io/slide-maker`. Deploy via `./deploy-staging.sh` (requires Tailscale/CUNY VPN). Traffic: Cloudflare -> Caddy (TLS) -> Nginx -> PM2 (API on 3004, web on 4173). - -See `CLAUDE.md` for full deployment details and server layout. +Institutional integration is source-only until the receiver release and private reachability are reviewed. See [the release proposal](docs/fleet-integration.md). Historical staging scripts are not authorization to deploy this branch. ## Contributing See `AGENTS.md` for coding style, testing guidelines, and commit conventions. + +Architecture proposals #11 and #12 are reconciled in the +[September 7 design review](docs/superpowers/specs/2026-09-07-architecture-reconciliation.md), +which separates current behavior, proposed consolidation, and unresolved host/data decisions. diff --git a/apps/api/package.json b/apps/api/package.json index 7193187..bbdf654 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,12 +8,16 @@ "build": "tsc", "db:push": "drizzle-kit push", "db:seed": "tsx src/db/seed.ts", - "seed:admin": "tsx src/db/seed.ts --admin" + "seed:admin": "tsx src/db/seed.ts --admin", + "identity:migrate": "tsx scripts/migrate-identity.ts", + "identity:link": "tsx scripts/link-identity.ts", + "state:snapshot": "tsx scripts/state-snapshot.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", - "@aws-sdk/client-bedrock-runtime": "^3.676.0", "@aws-sdk/client-ses": "^3.1029.0", + "@cuny-ai-lab/cail-client": "6.2.2", + "@cuny-ai-lab/cail-identity": "5.2.6", + "@cuny-ai-lab/cail-log": "0.6.4", "@hono/node-server": "^1.19.11", "@lucia-auth/adapter-drizzle": "^1.1.0", "@node-rs/argon2": "^2.0.0", @@ -27,7 +31,6 @@ "lucia": "^3.2.0", "mammoth": "^1.7.2", "nodemailer": "^6.10.0", - "openai": "^4.80.0", "pdf-parse": "^1.1.1", "rate-limiter-flexible": "^10.0.1", "sanitize-html": "^2.17.2", diff --git a/apps/api/scripts/link-identity.ts b/apps/api/scripts/link-identity.ts new file mode 100644 index 0000000..a04e082 --- /dev/null +++ b/apps/api/scripts/link-identity.ts @@ -0,0 +1,11 @@ +import { sqlite } from '../src/db/index.js' +import { migrateIdentityMapping, linkVerifiedIdentity } from '../src/db/identity-mapping.js' + +const [userId, subject, verification] = process.argv.slice(2) +if (!userId || !subject || verification !== '--operator-verified') { + throw new Error('Usage: bun apps/api/scripts/link-identity.ts --operator-verified; verify account ownership independently first, never by email alone') +} +migrateIdentityMapping(sqlite) +linkVerifiedIdentity(sqlite, userId, subject) +console.log('Verified identity linked; existing ownership and local role preserved') +sqlite.close() diff --git a/apps/api/scripts/migrate-identity.ts b/apps/api/scripts/migrate-identity.ts new file mode 100644 index 0000000..d09f04b --- /dev/null +++ b/apps/api/scripts/migrate-identity.ts @@ -0,0 +1,4 @@ +import { sqlite } from '../src/db/index.js' +import { migrateIdentityMapping } from '../src/db/identity-mapping.js' +migrateIdentityMapping(sqlite) +sqlite.close() diff --git a/apps/api/scripts/state-snapshot.ts b/apps/api/scripts/state-snapshot.ts new file mode 100644 index 0000000..591435a --- /dev/null +++ b/apps/api/scripts/state-snapshot.ts @@ -0,0 +1,16 @@ +import { createStateSnapshot, restoreStateSnapshot, verifyStateSnapshot } from '../src/db/state-snapshot.js' + +const [action, ...args] = process.argv.slice(2) +if (action === 'backup' && args.length === 4 && args[3] === '--writers-stopped') { + const result = await createStateSnapshot(args[0]!, args[1]!, args[2]!) + console.log(`Verified snapshot created (${result.files} files).`) +} else if (action === 'verify' && args.length === 1) { + await verifyStateSnapshot(args[0]!) + console.log('Snapshot files and SQLite integrity verified.') +} else if (action === 'restore-new' && args.length === 2) { + await restoreStateSnapshot(args[0]!, args[1]!) + console.log('Snapshot restored and verified in a new directory. No running service was changed.') +} else { + console.error('Usage: state-snapshot backup DATABASE UPLOADS NEW_DESTINATION --writers-stopped | verify SNAPSHOT | restore-new SNAPSHOT NEW_DESTINATION') + process.exitCode = 2 +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..4dab83c --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { bodyLimit } from 'hono/body-limit' +import { csrf } from 'hono/csrf' +import { env } from './env.js' +import auth from './routes/auth.js' +import admin from './routes/admin.js' +import { decksRouter } from './routes/decks.js' +import exportRouter from './routes/export.js' +import previewRouter from './routes/preview.js' +import chat from './routes/chat.js' +import providers from './routes/providers.js' +import resources from './routes/resources.js' +import artifactRouter from './routes/artifact.js' +import sharing from './routes/sharing.js' +import filesRouter from './routes/files.js' +import search from './routes/search.js' +import planRouter from './routes/plan.js' +import debugRouter from './routes/debug.js' +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' +import { logRequestFailure } from './fleet/log.js' +import { readinessRouter } from './fleet/readiness.js' +import { loadIdentityConfigs } from './auth/identity.js' +import { sqlite } from './db/index.js' + +const app = new Hono() + +// Global error handler — ensures CORS headers are present on unhandled exceptions +// so the browser can read the error instead of showing "NetworkError" +app.onError((err, c) => { + const origin = c.req.header('origin') + if (origin && env.allowedOrigins.includes(origin)) { + c.header('Access-Control-Allow-Origin', origin) + c.header('Access-Control-Allow-Credentials', 'true') + } + logRequestFailure(correlationFromHeaders(c.req.raw.headers).request_id) + return c.json({ error: 'Internal server error' }, 500) +}) + +app.use('/*', cors({ + origin: env.allowedOrigins, + credentials: true, +})) + +app.use('/*', csrf({ origin: env.allowedOrigins })) +// 11MB for file upload routes (10MB file + overhead) +app.use('/api/decks/:id/files', bodyLimit({ maxSize: 11 * 1024 * 1024 })) +// Apply a 2MB limit to all other routes, but explicitly skip the upload endpoint +const smallBodyLimit = bodyLimit({ maxSize: 2 * 1024 * 1024 }) +app.use('*', async (c, next) => { + const { pathname } = new URL(c.req.url) + // Skip when path matches /api/decks/:deckId/files exactly + if (/^\/api\/decks\/[^/]+\/files$/.test(pathname)) { + return next() + } + return smallBodyLimit(c, next) +}) + +app.get('/', (c) => c.json({ name: 'slide-wiz-dev', status: 'ok' })) +app.get('/api/health', (c) => c.json({ status: 'ok' })) +app.route('/internal', readinessRouter(() => { + sqlite.prepare('SELECT canonical_subject FROM users LIMIT 1').all() +}, loadIdentityConfigs)) + +// Serve static files (Leaflet, etc.) for artifact iframes +app.get('/api/static/:file', async (c) => { + const file = c.req.param('file') + const fs = await import('node:fs') + const path = await import('node:path') + const { fileURLToPath } = await import('node:url') + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const filePath = path.join(__dirname, '..', 'static', path.basename(file)) + if (!fs.existsSync(filePath)) return c.text('Not found', 404) + const content = fs.readFileSync(filePath, 'utf-8') + const ext = path.extname(file) + const type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/plain' + c.header('Content-Type', type) + c.header('Cache-Control', 'public, max-age=86400') + c.header('Access-Control-Allow-Origin', '*') + return c.body(content) +}) + +app.route('/api/auth', auth) +app.route('/api/admin', admin) +app.route('/api/decks', filesRouter) +app.route('/api/decks', decksRouter) +app.route('/api/decks', exportRouter) +app.route('/api/decks', previewRouter) +app.route('/api/decks', sharing) +app.route('/api/decks', planRouter) +app.route('/api/chat', chat) +app.route('/api/providers', providers) +app.route('/api', resources) +app.route('/api', artifactRouter) +app.route('/api/search', search) + +// Mount debug routes only when explicitly enabled (requires admin auth) +if (process.env.ENABLE_DEBUG_ROUTES === 'true') { + app.route('/api/debug', debugRouter) +} + +export default app diff --git a/apps/api/src/auth/identity.ts b/apps/api/src/auth/identity.ts new file mode 100644 index 0000000..a699a4d --- /dev/null +++ b/apps/api/src/auth/identity.ts @@ -0,0 +1,20 @@ +import { loadIdentityVerifierConfig, readIdentityKeyring, verifyIdentityJwt, verifyKeyringGatewayJwt, type IdentityVerifierConfig } from '@cuny-ai-lab/cail-identity' + +export async function loadIdentityConfigs() { + const input = { jwks: process.env.CAIL_IDENTITY_JWKS, issuer: process.env.CAIL_IDENTITY_ISSUER } + const [app, gateway] = await Promise.all([ + loadIdentityVerifierConfig({ ...input, expectedAudience: 'cail:slide-maker' }), + loadIdentityVerifierConfig({ ...input, expectedAudience: 'cail:gateway' }), + ]) + if (!app.ok || !gateway.ok) throw new Error('Invalid CAIL identity verifier configuration') + return { app: app.config, gateway: gateway.config } +} + +export async function verifyRequestIdentity(headers: Headers, configs: { app: IdentityVerifierConfig; gateway: IdentityVerifierConfig }) { + const keyring = readIdentityKeyring(headers) + if (!keyring) return null + const app = await verifyIdentityJwt(keyring.appJwt, configs.app) + if (!app) return null + if (keyring.gatewayJwt && !await verifyKeyringGatewayJwt(keyring, configs.gateway, app.subject)) return null + return { subject: app.subject, gatewayToken: keyring.gatewayJwt } +} diff --git a/apps/api/src/db/identity-mapping.ts b/apps/api/src/db/identity-mapping.ts new file mode 100644 index 0000000..8f96097 --- /dev/null +++ b/apps/api/src/db/identity-mapping.ts @@ -0,0 +1,19 @@ +import type Database from 'better-sqlite3' +import { isCailSubject } from '@cuny-ai-lab/cail-identity' + +/** Additive migration: existing user IDs, ownership and local roles are untouched. */ +export function migrateIdentityMapping(sqlite: Database.Database) { + sqlite.transaction(() => { + const columns = sqlite.prepare('PRAGMA table_info(users)').all() as { name: string }[] + if (!columns.some((column) => column.name === 'canonical_subject')) { + sqlite.exec('ALTER TABLE users ADD COLUMN canonical_subject TEXT') + } + sqlite.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_canonical_subject_unique ON users(canonical_subject)') + })() +} + +export function linkVerifiedIdentity(sqlite: Database.Database, userId: string, subject: string) { + if (!isCailSubject(subject)) throw new Error('A verified canonical CAIL subject is required') + const result = sqlite.prepare('UPDATE users SET canonical_subject = ? WHERE id = ? AND canonical_subject IS NULL').run(subject, userId) + if (result.changes !== 1) throw new Error('User missing or already linked; refusing replacement') +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7ebcacb..ed57a6a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' // ── Auth ── export const users = sqliteTable('users', { + canonicalSubject: text('canonical_subject').unique(), id: text('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), diff --git a/apps/api/src/db/state-snapshot.ts b/apps/api/src/db/state-snapshot.ts new file mode 100644 index 0000000..f6db8b3 --- /dev/null +++ b/apps/api/src/db/state-snapshot.ts @@ -0,0 +1,102 @@ +import Database from 'better-sqlite3' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { copyFile, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +type FileRecord = { path: string; sha256: string; bytes: number } +type Manifest = { version: 1; files: FileRecord[] } + +async function digest(path: string) { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('hex') +} + +async function filesAt(root: string, prefix = ''): Promise { + const result: FileRecord[] = [] + for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) result.push(...await filesAt(root, path)) + else if (entry.isFile()) result.push({ path, sha256: await digest(join(root, path)), bytes: (await stat(join(root, path))).size }) + else throw new Error('Snapshots require regular files and directories; symlinks are not copied') + } + return result.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +function checkDatabase(path: string) { + const db = new Database(path, { readonly: true, fileMustExist: true }) + try { + const foreignKeyErrors = db.pragma('foreign_key_check') + if (db.pragma('quick_check', { simple: true }) !== 'ok' || !Array.isArray(foreignKeyErrors) || foreignKeyErrors.length !== 0) { + throw new Error('Snapshot database integrity check failed') + } + } finally { db.close() } +} + +async function copyFiles(source: string, destination: string, records: FileRecord[]) { + for (const file of records) { + const target = join(destination, file.path) + await mkdir(dirname(target), { recursive: true }) + // Destination is newly created; exclusive copies refuse unexpected writes. + await copyFile(join(source, file.path), target, 1) + } +} + +/** The caller must stop every API/upload writer until this promise settles. */ +export async function createStateSnapshot(databasePath: string, uploadsPath: string, destination: string) { + const source = await realpath(uploadsPath) + const target = join(await realpath(dirname(resolve(destination))), basename(resolve(destination))) + const withinSource = relative(source, target) + if (withinSource === '' || (!withinSource.startsWith(`..${sep}`) && withinSource !== '..' && !isAbsolute(withinSource))) { + throw new Error('Snapshot destination must be outside the uploads directory') + } + const originalFiles = await filesAt(source) + await mkdir(target, { mode: 0o700 }) // Refuses existing destinations. + const db = new Database(databasePath, { readonly: true, fileMustExist: true }) + try { await db.backup(join(target, 'database.sqlite')) } finally { db.close() } + // Convert only the independent snapshot to a self-contained rollback journal; + // verification must not create WAL/SHM sidecars inside an immutable snapshot. + const snapshotDb = new Database(join(target, 'database.sqlite')) + try { snapshotDb.pragma('journal_mode = DELETE') } finally { snapshotDb.close() } + await mkdir(join(target, 'uploads'), { mode: 0o700 }) + await copyFiles(source, join(target, 'uploads'), originalFiles) + if (JSON.stringify(originalFiles) !== JSON.stringify(await filesAt(source))) { + throw new Error('Uploads changed during backup; keep writers stopped and take a new snapshot') + } + checkDatabase(join(target, 'database.sqlite')) + const manifest: Manifest = { version: 1, files: await filesAt(target) } + await writeFile(join(target, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx', mode: 0o600 }) + await verifyStateSnapshot(target) + return { files: manifest.files.length } +} + +export async function verifyStateSnapshot(directory: string) { + const raw = await readFile(join(directory, 'manifest.json')) + if (raw.length > 16 * 1024 * 1024) throw new Error('Snapshot manifest is too large') + const manifest = JSON.parse(raw.toString()) as Manifest + if (manifest.version !== 1 || !Array.isArray(manifest.files) || !manifest.files.some(file => file.path === 'database.sqlite')) { + throw new Error('Invalid snapshot manifest') + } + for (const file of manifest.files) { + if (typeof file.path !== 'string' || file.path.includes('\\') || file.path.split('/').some(part => part === '..' || part === '.' || part === '') + || (file.path !== 'database.sqlite' && !file.path.startsWith('uploads/')) + || !/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new Error('Invalid snapshot file record') + } + } + const actual = (await filesAt(directory)).filter(file => file.path !== 'manifest.json') + if (JSON.stringify(actual) !== JSON.stringify(manifest.files)) throw new Error('Snapshot files do not match their manifest') + checkDatabase(join(directory, 'database.sqlite')) + return manifest +} + +/** Restore only into a new directory; never replaces a live database or upload. */ +export async function restoreStateSnapshot(directory: string, destination: string) { + const manifest = await verifyStateSnapshot(directory) + await mkdir(destination, { mode: 0o700 }) + await mkdir(join(destination, 'uploads'), { mode: 0o700 }) + await copyFiles(directory, destination, manifest.files) + await copyFile(join(directory, 'manifest.json'), join(destination, 'manifest.json'), 1) + await verifyStateSnapshot(destination) +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 7dcaac4..c49deb5 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -1,24 +1,6 @@ import 'dotenv/config' import { resolveApiHost } from './listen-host.js' -// Accept provider selection via env or CLI flag: --provider -function parseProvider(input?: string | null): '' | 'bedrock' | 'anthropic' | 'openrouter' { - const v = (input || '').trim().toLowerCase() - if (v === 'bedrock' || v === 'anthropic' || v === 'openrouter') return v - return '' -} - -function getCliArg(name: string): string | undefined { - const idx = process.argv.findIndex((a) => a === `--${name}`) - if (idx !== -1 && idx + 1 < process.argv.length) return process.argv[idx + 1] - const pref = `--${name}=` - const m = process.argv.find((a) => a.startsWith(pref)) - if (m) return m.slice(pref.length) - return undefined -} - -const cliProvider = getCliArg('provider') - export const env = { host: resolveApiHost(), port: Number(process.env.API_PORT ?? 3001), @@ -36,16 +18,6 @@ export const env = { from: process.env.SES_FROM_EMAIL ?? 'ailab@gc.cuny.edu', enabled: process.env.EMAIL_PROVIDER === 'ses', }, - anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '', - openrouterApiKey: process.env.OPENROUTER_API_KEY ?? '', - // AWS Bedrock uses the standard AWS credential chain; we detect enablement via region - awsRegion: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || process.env.BEDROCK_REGION || '', - bedrockApiKey: process.env.BEDROCK_API_KEY || '', - // Optional, to expose admin-only higher tier models - bedrockSonnet46ModelId: process.env.BEDROCK_SONNET_46_MODEL_ID || '', - anthropicSonnet46Id: process.env.ANTHROPIC_SONNET_46_MODEL_ID || '', - // Provider filter (optional): '', 'bedrock', 'anthropic', 'openrouter' - aiProvider: parseProvider(cliProvider || process.env.AI_PROVIDER || null), tavilyApiKey: process.env.TAVILY_API_KEY ?? '', braveApiKey: process.env.BRAVE_API_KEY ?? '', pexelsApiKey: process.env.PEXELS_API_KEY ?? '', @@ -74,18 +46,6 @@ if (process.env.NODE_ENV === 'production' && !process.env.SESSION_SECRET) { // Validate critical env vars on startup const warnings: string[] = [] -if (!env.openrouterApiKey && !env.anthropicApiKey && !env.awsRegion) { - warnings.push('No AI provider configured (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, or AWS_REGION for Bedrock) — chat will not work') -} -if (env.aiProvider === 'openrouter' && !env.openrouterApiKey) { - warnings.push('AI_PROVIDER=openrouter but OPENROUTER_API_KEY is not set') -} -if (env.aiProvider === 'anthropic' && !env.anthropicApiKey) { - warnings.push('AI_PROVIDER=anthropic but ANTHROPIC_API_KEY is not set') -} -if (env.aiProvider === 'bedrock' && !env.awsRegion) { - warnings.push('AI_PROVIDER=bedrock but AWS_REGION is not set') -} if (!env.smtp.host && !env.ses.enabled) { warnings.push('No email provider configured — set SMTP_HOST or EMAIL_PROVIDER=ses') } diff --git a/apps/api/src/fleet/log.ts b/apps/api/src/fleet/log.ts new file mode 100644 index 0000000..103d0ef --- /dev/null +++ b/apps/api/src/fleet/log.ts @@ -0,0 +1,33 @@ +import { createCailLogger, defineEventCatalog } from '@cuny-ai-lab/cail-log' + +const catalog = defineEventCatalog({ + 'slide-maker.gateway.completed': { + source: 'tenant', severity: 'outcome', + required: ['request_id', 'terminal'], optional: [], + }, + 'slide-maker.request.failed': { + source: 'tenant', severity: 'error', + required: ['request_id', 'terminal'], optional: [], + }, +}) +const log = createCailLogger({ + service: 'slide-maker', release: process.env.RELEASE_SHA ?? 'development', + env: process.env.NODE_ENV === 'production' ? 'production' : 'development', + sourceClass: 'tenant', catalog, + sink: (event) => { console.log(JSON.stringify(event)) }, +}) + +export function logGatewayOutcome(requestId: string, outcome: 'ok' | 'error' | 'cancelled') { + const terminal = outcome === 'ok' + ? { outcome: 'ok', reason: 'completed' } as const + : outcome === 'cancelled' + ? { outcome: 'cancelled', reason: 'cancelled' } as const + : { outcome: 'error', reason: 'upstream_failure' } as const + log.emit('slide-maker.gateway.completed', { request_id: requestId, terminal }) +} + +export function logRequestFailure(requestId: string) { + log.emit('slide-maker.request.failed', { + request_id: requestId, terminal: { outcome: 'error', reason: 'application_failure' }, + }) +} diff --git a/apps/api/src/fleet/readiness.ts b/apps/api/src/fleet/readiness.ts new file mode 100644 index 0000000..204fcd3 --- /dev/null +++ b/apps/api/src/fleet/readiness.ts @@ -0,0 +1,35 @@ +import { timingSafeEqual } from 'node:crypto' +import { Hono } from 'hono' + +function validGatewayOrigin(value: string | undefined) { + try { + const url = new URL(value ?? '') + return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' + && (url.origin === 'https://tools.ailab.gc.cuny.edu' || (process.env.NODE_ENV === 'test' + && url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname))) + } catch { return false } +} + +export function readinessRouter(checkStorage: () => void, checkIdentity: () => unknown | Promise) { + const router = new Hono() + router.get('/ready', async (c) => { + const configured = process.env.READINESS_TOKEN + const authorization = c.req.header('authorization') + const supplied = authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined + if (!configured || !supplied || Buffer.byteLength(configured) !== Buffer.byteLength(supplied) + || !timingSafeEqual(Buffer.from(configured), Buffer.from(supplied))) { + return c.json({ error: 'Not found' }, 404) + } + const release = process.env.RELEASE_SHA + try { + if (!release || !/^[a-f0-9]{40}$/.test(release)) throw new Error('release') + if (!validGatewayOrigin(process.env.CAIL_GATEWAY_URL)) throw new Error('gateway') + await checkIdentity() + checkStorage() + } catch { + return c.json({ status: 'not_ready' }, 503) + } + return c.json({ status: 'ready', release, service: 'slide-maker' }) + }) + return router +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb51175..8dd4f4c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,105 +1,9 @@ import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { bodyLimit } from 'hono/body-limit' -import { csrf } from 'hono/csrf' +import app from './app.js' import { env } from './env.js' -import auth from './routes/auth.js' -import admin from './routes/admin.js' -import { decksRouter } from './routes/decks.js' -import exportRouter from './routes/export.js' -import previewRouter from './routes/preview.js' -import chat from './routes/chat.js' -import providers from './routes/providers.js' -import resources from './routes/resources.js' -import artifactRouter from './routes/artifact.js' -import sharing from './routes/sharing.js' -import filesRouter from './routes/files.js' -import search from './routes/search.js' -import planRouter from './routes/plan.js' -import debugRouter from './routes/debug.js' - -const app = new Hono() - -// Global error handler — ensures CORS headers are present on unhandled exceptions -// so the browser can read the error instead of showing "NetworkError" -app.onError((err, c) => { - const origin = c.req.header('origin') - if (origin && env.allowedOrigins.includes(origin)) { - c.header('Access-Control-Allow-Origin', origin) - c.header('Access-Control-Allow-Credentials', 'true') - } - console.error('Unhandled error:', err.message, err.stack) - return c.json({ error: 'Internal server error' }, 500) -}) - -app.use('/*', cors({ - origin: env.allowedOrigins, - credentials: true, -})) - -app.use('/*', csrf({ origin: env.allowedOrigins })) -// 11MB for file upload routes (10MB file + overhead) -app.use('/api/decks/:id/files', bodyLimit({ maxSize: 11 * 1024 * 1024 })) -// Apply a 2MB limit to all other routes, but explicitly skip the upload endpoint -const smallBodyLimit = bodyLimit({ maxSize: 2 * 1024 * 1024 }) -app.use('*', async (c, next) => { - const { pathname } = new URL(c.req.url) - // Skip when path matches /api/decks/:deckId/files exactly - if (/^\/api\/decks\/[^/]+\/files$/.test(pathname)) { - return next() - } - return smallBodyLimit(c, next) -}) - -app.get('/', (c) => c.json({ name: 'slide-wiz-dev', status: 'ok' })) -app.get('/api/health', (c) => c.json({ status: 'ok' })) - -// Serve static files (Leaflet, etc.) for artifact iframes -app.get('/api/static/:file', async (c) => { - const file = c.req.param('file') - const fs = await import('node:fs') - const path = await import('node:path') - const { fileURLToPath } = await import('node:url') - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const filePath = path.join(__dirname, '..', 'static', path.basename(file)) - if (!fs.existsSync(filePath)) return c.text('Not found', 404) - const content = fs.readFileSync(filePath, 'utf-8') - const ext = path.extname(file) - const type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/plain' - c.header('Content-Type', type) - c.header('Cache-Control', 'public, max-age=86400') - c.header('Access-Control-Allow-Origin', '*') - return c.body(content) -}) - -app.route('/api/auth', auth) -app.route('/api/admin', admin) -app.route('/api/decks', filesRouter) -app.route('/api/decks', decksRouter) -app.route('/api/decks', exportRouter) -app.route('/api/decks', previewRouter) -app.route('/api/decks', sharing) -app.route('/api/decks', planRouter) -app.route('/api/chat', chat) -app.route('/api/providers', providers) -app.route('/api', resources) -app.route('/api', artifactRouter) -app.route('/api/search', search) - -// Mount debug routes only when explicitly enabled (requires admin auth) -if (process.env.ENABLE_DEBUG_ROUTES === 'true') { - app.route('/api/debug', debugRouter) -} const displayHost = env.host.includes(':') ? `[${env.host}]` : env.host - -serve({ - fetch: app.fetch, - hostname: env.host, - port: env.port, -}, () => { +serve({ fetch: app.fetch, hostname: env.host, port: env.port }, () => { console.log(`API server running on http://${displayHost}:${env.port}`) }) - export default app diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 0d5194e..800e446 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,8 +1,28 @@ import type { Context, Next } from 'hono' import { getCookie } from 'hono/cookie' +import type { User, Session } from 'lucia' +import { eq } from 'drizzle-orm' +import { db } from '../db/index.js' +import { users } from '../db/schema.js' +import { loadIdentityConfigs, verifyRequestIdentity } from '../auth/identity.js' import { lucia } from '../auth/lucia.js' +export type AuthEnv = { Variables: { user: User; session: Session | null; gatewayToken: string | undefined; canonicalSubject: string | undefined } } + export async function authMiddleware(c: Context, next: Next) { + if (process.env.NODE_ENV === 'production' || c.req.header('x-cail-identity-jwt') !== undefined || c.req.header('x-cail-gateway-identity-jwt') !== undefined) { + let configs + try { configs = await loadIdentityConfigs() } catch { return c.json({ error: 'Identity unavailable' }, 503) } + const identity = await verifyRequestIdentity(c.req.raw.headers, configs) + if (!identity) return c.json({ error: 'Unauthorized' }, 401) + const user = db.select().from(users).where(eq(users.canonicalSubject, identity.subject)).get() + if (!user || user.status !== 'approved') return c.json({ error: 'Account requires operator verification' }, 403) + c.set('user', { id: user.id, email: user.email, name: user.name, role: user.role, status: user.status, emailVerified: user.emailVerified }) + c.set('session', null) + c.set('canonicalSubject', identity.subject) + c.set('gatewayToken', identity.gatewayToken) + return next() + } const sessionId = getCookie(c, lucia.sessionCookieName) if (!sessionId) { return c.json({ error: 'Unauthorized' }, 401) diff --git a/apps/api/src/providers/anthropic.ts b/apps/api/src/providers/anthropic.ts deleted file mode 100644 index d046efb..0000000 --- a/apps/api/src/providers/anthropic.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk' -import { env } from '../env.js' - -export type SplitSystemPrompt = { staticPrompt: string; dynamicContext: string } - -const client = new Anthropic({ apiKey: env.anthropicApiKey }) - -export async function* streamAnthropic( - system: string | SplitSystemPrompt, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'claude-sonnet-4-20250514', -): AsyncGenerator { - const systemBlocks: Anthropic.Messages.TextBlockParam[] = typeof system === 'string' - ? [{ type: 'text', text: system }] - : [ - { type: 'text', text: system.staticPrompt, cache_control: { type: 'ephemeral' } }, - { type: 'text', text: system.dynamicContext }, - ] - - const stream = client.messages.stream({ - model, - max_tokens: 4096, - system: systemBlocks, - messages, - }) - - for await (const event of stream) { - if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - yield event.delta.text - } - } -} - -export const ANTHROPIC_MODELS = [ - { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', provider: 'anthropic' }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', provider: 'anthropic' }, -] - -// Optionally expose Sonnet 4.6 via Anthropic SDK (admin-only in listing) -if (env.anthropicSonnet46Id) { - ;(ANTHROPIC_MODELS as any).push({ - id: env.anthropicSonnet46Id, - name: 'Claude Sonnet 4.6', - provider: 'anthropic', - adminOnly: true, - }) -} diff --git a/apps/api/src/providers/bedrock.ts b/apps/api/src/providers/bedrock.ts deleted file mode 100644 index f870d9d..0000000 --- a/apps/api/src/providers/bedrock.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { env } from '../env.js' - -/** - * AWS Bedrock via native SDK with IAM credentials. - * Supports both IAM auth (AWS_ACCESS_KEY_ID/SECRET) and ABSK bearer token (Mantle API). - * Set AWS_REGION and provide credentials via env vars or AWS credential chain. - */ - -// Lazy import to avoid requiring the package unless used -async function loadBedrock() { - const mod = await import('@aws-sdk/client-bedrock-runtime') - return { - BedrockRuntimeClient: mod.BedrockRuntimeClient, - ConverseStreamCommand: mod.ConverseStreamCommand, - } -} - -export async function* streamBedrock( - systemPrompt: string, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'us.anthropic.claude-haiku-4-5-20251001-v1:0', -): AsyncGenerator { - const { BedrockRuntimeClient, ConverseStreamCommand } = await loadBedrock() - - const region = env.awsRegion || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1' - - const clientConfig: Record = { region } - - // Explicit IAM credentials from env (takes precedence over credential chain) - const accessKey = process.env.AWS_ACCESS_KEY_ID - const secretKey = process.env.AWS_SECRET_ACCESS_KEY - if (accessKey && secretKey) { - clientConfig.credentials = { - accessKeyId: accessKey, - secretAccessKey: secretKey, - ...(process.env.AWS_SESSION_TOKEN ? { sessionToken: process.env.AWS_SESSION_TOKEN } : {}), - } - } - - const client = new BedrockRuntimeClient(clientConfig) - - const command = new ConverseStreamCommand({ - modelId: model, - system: [{ text: systemPrompt }], - messages: messages.map((m) => ({ - role: m.role, - content: [{ text: m.content }], - })), - inferenceConfig: { - maxTokens: 4096, - }, - }) - - const res = await client.send(command) - - if (res.stream) { - for await (const event of res.stream) { - if (event.contentBlockDelta?.delta?.text) { - yield event.contentBlockDelta.delta.text - } - } - } -} - -export const BEDROCK_MODELS = [ - { id: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', name: 'Claude Haiku 4.5 (AWS)', provider: 'bedrock' }, - { id: 'us.anthropic.claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (AWS)', provider: 'bedrock' }, -] diff --git a/apps/api/src/providers/index.ts b/apps/api/src/providers/index.ts index 7403687..998ee38 100644 --- a/apps/api/src/providers/index.ts +++ b/apps/api/src/providers/index.ts @@ -1,27 +1,119 @@ -import { streamAnthropic, ANTHROPIC_MODELS, type SplitSystemPrompt } from './anthropic.js' -import { streamOpenRouter, OPENROUTER_MODELS } from './openrouter.js' -import { streamBedrock, BEDROCK_MODELS } from './bedrock.js' -import { env } from '../env.js' +import { correlationFromHeaders, type CailCorrelation } from '@cuny-ai-lab/cail-log' +import { CailError, createCailClient, extractCailError, type CailClient } from '@cuny-ai-lab/cail-client' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import { logGatewayOutcome } from '../fleet/log.js' -export type { SplitSystemPrompt } +export const gateway = createCailClient({ app: 'slide-maker', allowInsecureLoopback: process.env.NODE_ENV === 'test', ...(process.env.CAIL_GATEWAY_URL ? { baseUrl: process.env.CAIL_GATEWAY_URL } : {}) }) +export type SplitSystemPrompt = { staticPrompt: string; dynamicContext: string } -export const ALL_MODELS = [...ANTHROPIC_MODELS, ...OPENROUTER_MODELS, ...BEDROCK_MODELS] +export function gatewayErrorStatus(error: unknown): ContentfulStatusCode { + if (error instanceof CailError && [400, 401, 403, 404, 409, 429, 503].includes(error.status)) return error.status as ContentfulStatusCode + return 502 +} + +export function safeGatewayError(error: unknown) { + const requestId = error instanceof CailError ? error.extras.request_id : undefined + const code = error instanceof CailError && /^[a-z][a-z_]{1,63}$/.test(error.code) ? error.code : 'upstream_failure' + const shouldRetry = error instanceof CailError && typeof error.extras.should_retry === 'boolean' ? error.extras.should_retry : undefined + return { + message: code === 'quota_exceeded' ? 'The CAIL model usage limit has been reached. Your deck is still available.' + : shouldRetry === true ? 'The model request could not be completed. Please try again later.' : 'The model request could not be completed.', + code, + ...(shouldRetry !== undefined ? { shouldRetry } : {}), + ...(typeof requestId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(requestId) + ? { requestId } : {}), + } +} -function flattenSystem(system: string | SplitSystemPrompt): string { - return typeof system === 'string' ? system : `${system.staticPrompt}\n\n${system.dynamicContext}` +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Read to EOF even after DONE: a trailing Gateway error invalidates the turn. */ +export async function* readGatewayStream(response: Response, signal: AbortSignal): AsyncGenerator { + if (!response.ok || !response.body) throw new Error('Gateway response unavailable') + const reader = response.body.getReader() + const cancel = () => { void reader.cancel().catch(() => {}) } + signal.addEventListener('abort', cancel, { once: true }) + const decoder = new TextDecoder() + let buffer = '' + let done = false + function parse(frame: string): string[] { + const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n') + if (!data) return [] + if (data === '[DONE]') { done = true; return [] } + const value: unknown = JSON.parse(data) + const gatewayError = extractCailError(value) + if (gatewayError) throw gatewayError + if (!record(value) || value.error || frame.split('\n').some(line => /^event:\s*error\s*$/.test(line))) throw new Error('Gateway stream error') + if (done && (!Array.isArray(value.choices) || value.choices.length !== 0 || !('usage' in value))) throw new Error('Unexpected data after completion') + if (!Array.isArray(value.choices)) throw new Error('Invalid Gateway chunk') + const text: string[] = [] + for (const choice of value.choices) { + if (!record(choice)) throw new Error('Invalid Gateway choice') + if (record(choice.delta) && typeof choice.delta.content === 'string') text.push(choice.delta.content) + } + return text + } + try { + while (true) { + signal.throwIfAborted() + const chunk = await reader.read() + signal.throwIfAborted() + buffer += decoder.decode(chunk.value, { stream: !chunk.done }) + // Normalize only complete CRLF sequences, preserving a CR split across reads. + buffer = buffer.replace(/\r\n/g, '\n') + let boundary: number + while ((boundary = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + yield* parse(frame) + } + if (chunk.done) break + } + if (buffer.trim()) yield* parse(buffer) + if (!done) throw new Error('Gateway stream ended before completion') + } finally { + signal.removeEventListener('abort', cancel) + await reader.cancel().catch(() => {}) + reader.releaseLock() + } } -export function getModelStream( +export async function* getModelStream( modelId: string, system: string | SplitSystemPrompt, messages: { role: 'user' | 'assistant'; content: string }[], + options: { token: string; sessionId: string; signal: AbortSignal; correlation?: CailCorrelation }, + client: CailClient = gateway, ): AsyncGenerator { - const model = ALL_MODELS.find((m) => m.id === modelId) - if (!model) throw new Error(`Unknown model: ${modelId}`) - if (env.aiProvider && model.provider !== env.aiProvider) { - throw new Error(`Model provider '${model.provider}' not allowed by AI_PROVIDER=${env.aiProvider}`) + const correlation = options.correlation ?? correlationFromHeaders(new Headers()) + const requestId = correlation.request_id + try { + const response = await client.chatCompletions({ + model: modelId, + stream: true, + stream_options: { include_usage: true }, + messages: [ + { role: 'system', content: typeof system === 'string' ? system : `${system.staticPrompt}\n\n${system.dynamicContext}` }, + ...messages, + ], + }, { kind: 'jwt', token: options.token }, { + sessionId: options.sessionId, + signal: options.signal, + correlation, + }) + yield* readGatewayStream(response, options.signal) + logGatewayOutcome(requestId, 'ok') + } catch (error) { + logGatewayOutcome(requestId, options.signal.aborted ? 'cancelled' : 'error') + if (options.signal.aborted) throw error + if (error instanceof CailError) { + throw new CailError(error.code, 'Gateway request failed', error.status, { + request_id: error.extras.request_id ?? requestId, + ...(typeof error.extras.should_retry === 'boolean' ? { should_retry: error.extras.should_retry } : {}), + }, error.type) + } + throw new CailError('upstream_failure', 'Gateway request failed', 502, { request_id: requestId }) } - if (model.provider === 'anthropic') return streamAnthropic(system, messages, modelId) - if (model.provider === 'bedrock') return streamBedrock(flattenSystem(system), messages, modelId) - return streamOpenRouter(flattenSystem(system), messages, modelId) } diff --git a/apps/api/src/providers/openrouter.ts b/apps/api/src/providers/openrouter.ts deleted file mode 100644 index 1a5b404..0000000 --- a/apps/api/src/providers/openrouter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import OpenAI from 'openai' -import { env } from '../env.js' - -const client = new OpenAI({ - baseURL: 'https://openrouter.ai/api/v1', - apiKey: env.openrouterApiKey, -}) - -export async function* streamOpenRouter( - systemPrompt: string, - messages: { role: 'user' | 'assistant'; content: string }[], - model: string = 'google/gemini-3.1-flash-lite-preview', -): AsyncGenerator { - const stream = await client.chat.completions.create({ - model, - stream: true, - messages: [ - { role: 'system', content: systemPrompt }, - ...messages, - ], - }) - - for await (const chunk of stream) { - const text = chunk.choices[0]?.delta?.content - if (text) yield text - } -} - -export const OPENROUTER_MODELS = [ - { id: 'moonshotai/kimi-k2.5', name: 'Kimi K2.5', provider: 'openrouter' }, - { id: 'z-ai/glm-5', name: 'GLM 5', provider: 'openrouter' }, - { id: 'google/gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash', provider: 'openrouter' }, - { id: 'qwen/qwen3.5-flash-02-23', name: 'Qwen 3.5 Flash', provider: 'openrouter' }, -] diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 3b437c1..a6de29e 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -88,7 +88,6 @@ admin.get('/users/all', async (c) => { status: u.status, emailVerified: u.emailVerified, createdAt: u.createdAt, - tokenCap: u.tokenCap, deckCount: deckCountMap.get(u.id) ?? 0, tokensUsed: tokenMap.get(u.id) ?? 0, lastActive: lastActiveMap.get(u.id) ?? null, @@ -130,9 +129,6 @@ admin.patch('/users/:id', async (c) => { updates.status = body.status } - if (body.tokenCap !== undefined && typeof body.tokenCap === 'number' && body.tokenCap >= 0) { - updates.tokenCap = body.tokenCap - } if (Object.keys(updates).length === 0) { return c.json({ error: 'No valid fields to update' }, 400) @@ -183,14 +179,12 @@ admin.get('/users/:id/usage', async (c) => { .orderBy(desc(sql`SUM(input_tokens + output_tokens)`)) const totalUsed = totalRow?.total ?? 0 - const cap = user.tokenCap ?? 1000000 return c.json({ userId: id, userName: user.name, - tokenCap: cap, totalUsed, - remaining: Math.max(0, cap - totalUsed), + source: 'historical_local_estimate', inputTotal: totalRow?.inputTotal ?? 0, outputTotal: totalRow?.outputTotal ?? 0, monthly, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 64eac74..3933048 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -3,23 +3,23 @@ import { hash, verify } from '@node-rs/argon2' import { createId } from '@paralleldrive/cuid2' import { eq } from 'drizzle-orm' import { isValidCunyEmail } from '@slide-maker/shared' -import type { Session, User } from 'lucia' import { db } from '../db/index.js' import { users, emailVerifications, passwordResets } from '../db/schema.js' import { lucia } from '../auth/lucia.js' -import { authMiddleware } from '../middleware/auth.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { loginRateLimit, registerRateLimit, passwordChangeRateLimit, forgotPasswordRateLimit } from '../middleware/rate-limit.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../email/index.js' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} - const auth = new Hono() +// Password-era account lifecycle is available only in local development. +auth.use('*', async (c, next) => { + if (process.env.NODE_ENV === 'production' && !c.req.path.endsWith('/me')) { + return c.json({ error: 'Use CAIL sign-in' }, 403) + } + return next() +}) + // POST /register auth.post('/register', registerRateLimit, async (c) => { const body = await c.req.json() @@ -144,6 +144,7 @@ auth.post('/login', loginRateLimit, async (c) => { name: user.name, role: user.role, status: user.status, + authentication: c.get('canonicalSubject') ? 'institutional' : 'local', }, }) }) @@ -151,7 +152,7 @@ auth.post('/login', loginRateLimit, async (c) => { // POST /logout auth.post('/logout', authMiddleware, async (c) => { const session = c.get('session') - await lucia.invalidateSession(session.id) + if (session) await lucia.invalidateSession(session.id) const blankCookie = lucia.createBlankSessionCookie() c.header('Set-Cookie', blankCookie.serialize()) return c.json({ message: 'Logged out successfully' }) @@ -167,6 +168,7 @@ auth.get('/me', authMiddleware, async (c) => { name: user.name, role: user.role, status: user.status, + authentication: c.get('canonicalSubject') ? 'institutional' : 'local', }, }) }) diff --git a/apps/api/src/routes/chat.ts b/apps/api/src/routes/chat.ts index ce275ed..34b0caa 100644 --- a/apps/api/src/routes/chat.ts +++ b/apps/api/src/routes/chat.ts @@ -1,26 +1,18 @@ +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' import { Hono } from 'hono' import { streamSSE } from 'hono/streaming' -import { eq, and, inArray, gte, sql } from 'drizzle-orm' +import { eq, and, inArray } from 'drizzle-orm' import { createId } from '@paralleldrive/cuid2' -import type { Session, User } from 'lucia' import { db } from '../db/index.js' -import { decks, deckAccess, slides, contentBlocks, chatMessages, templates, themes, uploadedFiles, users, tokenUsage, artifacts } from '../db/schema.js' -import { authMiddleware } from '../middleware/auth.js' +import { decks, deckAccess, slides, contentBlocks, chatMessages, templates, themes, uploadedFiles, artifacts } from '../db/schema.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { chatRateLimit } from '../middleware/rate-limit.js' -import { getModelStream, ALL_MODELS } from '../providers/index.js' +import { getModelStream, safeGatewayError } from '../providers/index.js' import { buildSystemPrompt } from '../prompts/system.js' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { debugBus } from '../debug/event-bus.js' -import { appendTranscript } from '../debug/transcript-log.js' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} const chat = new Hono() @@ -43,7 +35,7 @@ chat.post('/', chatRateLimit, async (c) => { .where(and(eq(deckAccess.deckId, deckId), eq(deckAccess.userId, user.id))) .get() - if (!access) { + if (!access || access.role === 'viewer') { return c.json({ error: 'Not found or no access' }, 404) } @@ -337,21 +329,9 @@ chat.post('/', chatRateLimit, async (c) => { chatHistory.push({ role: 'user', content: message }) - // Determine provider from registered models (supports openrouter, anthropic, bedrock) - const provider = (ALL_MODELS.find((m) => m.id === modelId)?.provider || 'unknown') as string - - // Check token cap - const yearStart = new Date(new Date().getFullYear(), 0, 1) - const usage = await db.select({ total: sql`SUM(input_tokens + output_tokens)` }) - .from(tokenUsage) - .where(and(eq(tokenUsage.userId, user.id), gte(tokenUsage.createdAt, yearStart))) - .get() - - const userRow = await db.select().from(users).where(eq(users.id, user.id)).get() - const cap = userRow?.tokenCap ?? 1000000 - if ((usage?.total ?? 0) >= cap) { - return c.json({ error: 'Token limit reached. Contact an admin.' }, 429) - } + const provider = 'cail-gateway' + const gatewayToken = c.get('gatewayToken') + if (!gatewayToken) return c.json({ error: 'Institutional sign-in required' }, 401) // Save user message to DB const userMsgId = createId() @@ -368,46 +348,22 @@ chat.post('/', chatRateLimit, async (c) => { // Stream response return streamSSE(c, async (stream) => { let fullResponse = '' - const streamTimeout = setTimeout(() => { stream.close() }, 120_000) // 2 min hard cap - const streamId = createId() - const startedAt = Date.now() - let chunkIndex = 0 - let totalChars = 0 - + const controller = new AbortController() + let clientDisconnected = false + const abort = () => { clientDisconnected = true; controller.abort() } + c.req.raw.signal.addEventListener('abort', abort, { once: true }) + stream.onAbort(abort) + if (c.req.raw.signal.aborted) abort() + const streamTimeout = setTimeout(() => controller.abort(), 120_000) try { - const gen = getModelStream(modelId, systemParts, chatHistory) - - // Emit start event - debugBus.emit('stream:start', { - streamId, - userId: user.id, - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - timestamp: new Date(startedAt).toISOString(), + const gen = getModelStream(modelId, systemParts, chatHistory, { + token: gatewayToken, sessionId: deckId, correlation: correlationFromHeaders(c.req.raw.headers), signal: controller.signal, }) - for await (const text of gen) { fullResponse += text - totalChars += text.length - await stream.writeSSE({ - data: JSON.stringify({ type: 'text', content: text }), - }) - // Emit chunk event - debugBus.emit('stream:chunk', { - streamId, - text, - chunkIndex: chunkIndex++, - elapsedMs: Date.now() - startedAt, - }) + await stream.writeSSE({ data: JSON.stringify({ type: 'text', content: text }) }) } - - await stream.writeSSE({ - data: JSON.stringify({ type: 'done' }), - }) + controller.signal.throwIfAborted() // Extract mutation blocks from assistant response (before persisting) const mutations = (() => { @@ -432,83 +388,15 @@ chat.post('/', chatRateLimit, async (c) => { createdAt: new Date(), }) - // Estimate and record token usage - const allInputLength = systemParts.staticPrompt.length + systemParts.dynamicContext.length + chatHistory.reduce((sum, m) => sum + (m.content?.length ?? 0), 0) - const inputTokens = Math.ceil(allInputLength / 4) - const outputTokens = Math.ceil(fullResponse.length / 4) - await db.insert(tokenUsage).values({ - id: createId(), - userId: user.id, - deckId, - provider, - model: modelId, - inputTokens, - outputTokens, - createdAt: new Date(), - }) - - // Emit done event - debugBus.emit('stream:done', { - streamId, - totalChars, - durationMs: Date.now() - startedAt, - inputTokens, - outputTokens, - mutations, - }) - - // Append transcript log - await appendTranscript({ - id: streamId, - timestamp: new Date().toISOString(), - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - inputTokens, - outputTokens, - durationMs: Date.now() - startedAt, - userMessage: message, - assistantMessage: fullResponse, - mutations, - error: null, - }) - } catch (err: unknown) { - console.error('AI streaming error:', err) - const errorMessage = 'An error occurred while generating the response' - await stream.writeSSE({ - data: JSON.stringify({ type: 'error', message: errorMessage }), - }) - // Emit error event - debugBus.emit('stream:error', { - streamId, - error: (err as any)?.message ?? String(err), - elapsedMs: Date.now() - startedAt, - }) - // Append transcript with error - try { - await appendTranscript({ - id: streamId, - timestamp: new Date().toISOString(), - userEmail: user.email, - deckId, - model: modelId, - provider, - systemPromptChars: systemParts.staticPrompt.length + systemParts.dynamicContext.length, - historyLength: chatHistory.length, - inputTokens: 0, - outputTokens: 0, - durationMs: Date.now() - startedAt, - userMessage: message, - assistantMessage: fullResponse, - mutations: [], - error: (err as any)?.message ?? String(err), - }) - } catch {} + await stream.writeSSE({ data: JSON.stringify({ type: 'done' }) }) + } catch (error: unknown) { + if (!clientDisconnected) { + await stream.writeSSE({ data: JSON.stringify({ type: 'error', ...safeGatewayError(error) }) }) + } } finally { clearTimeout(streamTimeout) + c.req.raw.signal.removeEventListener('abort', abort) + controller.abort() } }) }) diff --git a/apps/api/src/routes/plan.ts b/apps/api/src/routes/plan.ts index b7f69e0..55e96f6 100644 --- a/apps/api/src/routes/plan.ts +++ b/apps/api/src/routes/plan.ts @@ -1,7 +1,7 @@ +import { correlationFromHeaders } from '@cuny-ai-lab/cail-log' import { Hono } from 'hono' import { eq, and } from 'drizzle-orm' import { createId } from '@paralleldrive/cuid2' -import type { Session, User } from 'lucia' import { type FidelityMode, type DeckPlan, @@ -12,12 +12,11 @@ import { LAYOUTS, } from '@slide-maker/shared' import { db, sqlite } from '../db/index.js' -import { decks, deckAccess, slides, contentBlocks, uploadedFiles, templates, themes, users, tokenUsage } from '../db/schema.js' -import { gte, sql } from 'drizzle-orm' -import { authMiddleware } from '../middleware/auth.js' +import { decks, deckAccess, slides, contentBlocks, uploadedFiles, templates, themes } from '../db/schema.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' import { checkDeckLock } from '../middleware/deck-lock.js' import { chatRateLimit } from '../middleware/rate-limit.js' -import { getModelStream, ALL_MODELS } from '../providers/index.js' +import { getModelStream, safeGatewayError, gatewayErrorStatus } from '../providers/index.js' import { parseOutline } from '../utils/outline-parser.js' import { estimateSlideCount } from '../utils/slide-budget.js' import { buildPlannerPrompt } from '../prompts/planner.js' @@ -26,12 +25,6 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -type AuthEnv = { - Variables: { - user: User - session: Session - } -} const __dirname = path.dirname(fileURLToPath(import.meta.url)) const UPLOADS_DIR = path.resolve(__dirname, '../../uploads') @@ -163,18 +156,8 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { maxSlides: MAX_SLIDES_PER_DECK, }) - // Check token cap before calling AI - const provider = (ALL_MODELS.find(m => m.id === modelId)?.provider || 'unknown') as string - const yearStart = new Date(new Date().getFullYear(), 0, 1) - const usage = await db.select({ total: sql`SUM(input_tokens + output_tokens)` }) - .from(tokenUsage) - .where(and(eq(tokenUsage.userId, user.id), gte(tokenUsage.createdAt, yearStart))) - .get() - const userRow = await db.select().from(users).where(eq(users.id, user.id)).get() - const cap = userRow?.tokenCap ?? 1000000 - if ((usage?.total ?? 0) >= cap) { - return c.json({ error: 'Token limit reached. Contact an admin.' }, 429) - } + const gatewayToken = c.get('gatewayToken') + if (!gatewayToken) return c.json({ error: 'Institutional sign-in required' }, 401) // Call AI model and collect full response const messages: { role: 'user' | 'assistant'; content: string }[] = [ @@ -183,31 +166,14 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { let fullResponse = '' try { - const gen = getModelStream(modelId, systemPrompt, messages) + const gen = getModelStream(modelId, systemPrompt, messages, { token: gatewayToken, sessionId: deckId, correlation: correlationFromHeaders(c.req.raw.headers), signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(120_000)]) }) for await (const text of gen) { fullResponse += text } } catch (err: unknown) { - console.error('Planner AI error:', err) - return c.json({ error: 'Failed to generate plan' }, 500) + return c.json(safeGatewayError(err), gatewayErrorStatus(err)) } - // Record token usage - const userMsgContent = messages[0].content - const allInputLength = systemPrompt.length + userMsgContent.length - const inputTokens = Math.ceil(allInputLength / 4) - const outputTokens = Math.ceil(fullResponse.length / 4) - await db.insert(tokenUsage).values({ - id: createId(), - userId: user.id, - deckId, - provider, - model: modelId, - inputTokens, - outputTokens, - createdAt: new Date(), - }) - // Parse the AI response as JSON try { let cleaned = fullResponse.trim() @@ -216,7 +182,7 @@ planRouter.post('/:id/plan', chatRateLimit, async (c) => { } planData = JSON.parse(cleaned) } catch { - return c.json({ error: 'AI returned invalid JSON. Try again or use a different model.', raw: fullResponse.slice(0, 500) }, 422) + return c.json({ error: 'AI returned invalid JSON. Try again or use a different model.' }, 422) } } diff --git a/apps/api/src/routes/providers.ts b/apps/api/src/routes/providers.ts index 3106964..1f32912 100644 --- a/apps/api/src/routes/providers.ts +++ b/apps/api/src/routes/providers.ts @@ -1,49 +1,26 @@ import { Hono } from 'hono' -import type { Session, User } from 'lucia' -import { env } from '../env.js' -import { ANTHROPIC_MODELS } from '../providers/anthropic.js' -import { OPENROUTER_MODELS } from '../providers/openrouter.js' -import { BEDROCK_MODELS } from '../providers/bedrock.js' -import { authMiddleware } from '../middleware/auth.js' - -type AuthEnv = { - Variables: { - user: User - session: Session - } -} +import { gateway, safeGatewayError, gatewayErrorStatus } from '../providers/index.js' +import { authMiddleware, type AuthEnv } from '../middleware/auth.js' const providers = new Hono() - -// GET / — List available models (filtered by which API keys are configured) -providers.get('/', authMiddleware, (c) => { - const user = c.get('user') - const isAdmin = user?.role === 'admin' - - const list: any[] = [] - - if (env.anthropicApiKey) { - list.push(...ANTHROPIC_MODELS) - } - - if (env.openrouterApiKey) { - list.push(...OPENROUTER_MODELS) +providers.use('*', authMiddleware) +providers.get('/', async (c) => { + try { + const catalog = await gateway.getCatalogSnapshot({ modality: 'text', signal: c.req.raw.signal }) + return c.json({ models: catalog.data.filter(model => model.streaming).map(model => ({ + id: model.id, name: model.name ?? model.id, provider: 'cail-gateway', + })) }) + } catch (error) { + return c.json(safeGatewayError(error), gatewayErrorStatus(error)) } - - // Expose AWS Bedrock models if region is configured (credentials are resolved via SDK) - if (env.awsRegion) { - list.push(...BEDROCK_MODELS) +}) +providers.get('/quota', async (c) => { + const token = c.get('gatewayToken') + if (!token) return c.json({ error: 'Institutional sign-in required' }, 401) + try { + return c.json(await gateway.getQuota({ kind: 'jwt', token }, { signal: c.req.raw.signal })) + } catch (error) { + return c.json(safeGatewayError(error), gatewayErrorStatus(error)) } - - // Optional provider filter from env/CLI - const providerFilter = env.aiProvider - - const models = list - .filter((m) => (providerFilter ? m.provider === providerFilter : true)) - .filter((m) => (isAdmin ? true : !m.adminOnly)) - .map((m) => ({ id: m.id, name: m.name, provider: m.provider })) - - return c.json({ models }) }) - export default providers diff --git a/apps/web/src/lib/components/admin/UserApprovalQueue.svelte b/apps/web/src/lib/components/admin/UserApprovalQueue.svelte index 5be1275..19c357a 100644 --- a/apps/web/src/lib/components/admin/UserApprovalQueue.svelte +++ b/apps/web/src/lib/components/admin/UserApprovalQueue.svelte @@ -70,8 +70,6 @@ let usageLoading = $state(false); // Inline editing - let editingCapId = $state(null); - let editingCapValue = $state(''); const filteredUsers = $derived.by(() => { let filtered = allUsers; @@ -187,32 +185,6 @@ } } - function startEditCap(userId: string, currentCap: number) { - editingCapId = userId; - editingCapValue = String(currentCap); - } - - async function saveCapEdit(userId: string) { - const newCap = parseInt(editingCapValue, 10); - if (isNaN(newCap) || newCap < 0) { - editingCapId = null; - return; - } - actionInProgress = userId; - error = ''; - try { - await api.updateUser(userId, { tokenCap: newCap }); - const user = allUsers.find((u) => u.id === userId); - if (user) user.tokenCap = newCap; - allUsers = [...allUsers]; - } catch (err: any) { - error = err.message || 'Failed to update token cap'; - } finally { - actionInProgress = null; - editingCapId = null; - } - } - let resetInProgress = $state(null); let resetMessage = $state(''); @@ -302,7 +274,7 @@
{formatTokens(stats.totalTokens)} - Total Tokens Used + Historical token estimates (year)
@@ -339,8 +311,7 @@ toggleSort('role')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('role')}>Role{sortIndicator('role')} toggleSort('status')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('status')}>Status{sortIndicator('status')} toggleSort('deckCount')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('deckCount')}>Decks{sortIndicator('deckCount')} - toggleSort('tokensUsed')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('tokensUsed')}>Tokens{sortIndicator('tokensUsed')} - Cap + toggleSort('tokensUsed')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('tokensUsed')}>Historical tokens{sortIndicator('tokensUsed')} toggleSort('lastActive')} role="button" tabindex="0" onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && toggleSort('lastActive')}>Last Active{sortIndicator('lastActive')} Actions @@ -367,21 +338,6 @@ {u.deckCount} {formatTokens(u.tokensUsed)} - - {#if editingCapId === u.id} - { if (e.key === 'Enter') saveCapEdit(u.id); if (e.key === 'Escape') editingCapId = null; }} - onblur={() => saveCapEdit(u.id)} - /> - {:else} - - {/if} - {formatDate(u.lastActive)} {#if u.status === 'pending'} @@ -422,7 +378,7 @@