Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 11 additions & 20 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=
Expand All @@ -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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:

permissions:
contents: read
packages: read

concurrency:
group: slide-maker-ci-${{ github.workflow }}-${{ github.ref }}
Expand All @@ -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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@cuny-ai-lab:registry=https://npm.pkg.github.com
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
48 changes: 13 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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**

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
11 changes: 7 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions apps/api/scripts/link-identity.ts
Original file line number Diff line number Diff line change
@@ -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 <existing-user-id> <canonical-subject> --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()
4 changes: 4 additions & 0 deletions apps/api/scripts/migrate-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { sqlite } from '../src/db/index.js'
import { migrateIdentityMapping } from '../src/db/identity-mapping.js'
migrateIdentityMapping(sqlite)
sqlite.close()
16 changes: 16 additions & 0 deletions apps/api/scripts/state-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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
}
102 changes: 102 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions apps/api/src/auth/identity.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
19 changes: 19 additions & 0 deletions apps/api/src/db/identity-mapping.ts
Original file line number Diff line number Diff line change
@@ -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')
}
Loading
Loading