From a46d367822d7411cb463c91d9cb1838270fddb04 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Mon, 17 Aug 2026 04:21:33 +0530 Subject: [PATCH] refactor: retire Express handler bridge for learning actions Convert handlers/*.mjs to Fetch-style ({ request, user, json }), call them directly from dispatchLearningAction, and delete express-bridge.mjs. api/learning.mjs is now a thin wrapper around the same dispatcher. Closes #76 --- api/learning.mjs | 62 ++++++++++++++++++++++++++------ docs/architecture/overview.md | 2 +- docs/development/setup.md | 2 +- docs/knowledge/learnings.md | 7 ++++ handlers/activity.mjs | 26 +++++++------- handlers/artifacts.mjs | 21 ++++++----- handlers/concepts.mjs | 35 +++++++++--------- handlers/critique.mjs | 37 ++++++++++--------- handlers/drills.mjs | 21 ++++++----- handlers/elo.mjs | 23 ++++++------ handlers/feynman.mjs | 20 +++++------ handlers/gaps.mjs | 18 +++++----- handlers/imported-reviews.mjs | 26 +++++++------- handlers/learning-notes.mjs | 34 +++++++++--------- handlers/profile.mjs | 23 ++++++------ handlers/projects.mjs | 21 ++++++----- handlers/review-mastery.mjs | 32 ++++++++--------- handlers/tag.mjs | 16 ++++----- handlers/understanding-check.mjs | 31 ++++++++-------- handlers/weekly.mjs | 26 +++++++------- shared/api/express-bridge.mjs | 56 ----------------------------- shared/api/learning-registry.mjs | 5 +-- shared/api/parity.test.mjs | 39 +++++++++++++++++--- shared/api/read-json.mjs | 13 +++++++ shared/api/worker-learning.mjs | 10 ++++-- 25 files changed, 326 insertions(+), 280 deletions(-) delete mode 100644 shared/api/express-bridge.mjs create mode 100644 shared/api/read-json.mjs diff --git a/api/learning.mjs b/api/learning.mjs index 43c189af..421a9e7a 100644 --- a/api/learning.mjs +++ b/api/learning.mjs @@ -1,19 +1,59 @@ -// Consolidated handler — routes via ?action= (see shared/api/learning-registry.mjs). -import { HANDLER_MODULES, LEARNING_ACTIONS } from '../shared/api/learning-registry.mjs'; +// Leftover local dispatcher — same Fetch handlers as production. +// Production: functions/api/[[path]].js → dispatchLearningAction. +import { requireAuth } from './auth/verify.mjs'; +import { AUTH_ACTIONS } from '../shared/api/learning-registry.mjs'; +import { dispatchLearningAction } from '../shared/api/worker-learning.mjs'; +import { getDb } from '../shared/db/client.mjs'; + +function json(data, init = {}) { + const headers = new Headers(init.headers); + headers.set('content-type', 'application/json; charset=utf-8'); + return new Response(JSON.stringify(data ?? {}), { ...init, headers }); +} + +function toFetchRequest(req) { + const url = new URL(req.originalUrl || req.url || '/api/learning', 'http://localhost'); + for (const [key, value] of Object.entries(req.query || {})) { + if (value == null) continue; + url.searchParams.set(key, String(value)); + } + const headers = new Headers(); + const incoming = req.headers || {}; + if (incoming.authorization) headers.set('authorization', incoming.authorization); + if (incoming.cookie) headers.set('cookie', incoming.cookie); + const method = req.method || 'GET'; + const hasBody = method !== 'GET' && method !== 'HEAD'; + if (hasBody) { + headers.set('content-type', incoming['content-type'] || 'application/json'); + } + return new Request(url, { + method, + headers, + body: hasBody ? JSON.stringify(req.body ?? {}) : undefined, + }); +} export default async function handler(req, res) { const action = req.query?.action; - if (!action || !LEARNING_ACTIONS.includes(action)) { - return res.status(400).json({ - error: `Unknown action. Expected one of: ${LEARNING_ACTIONS.join(', ')}`, - }); + let user = req._authenticatedUser || null; + if (!user && AUTH_ACTIONS.includes(action)) { + user = await requireAuth(req, res); + if (!user) return; } - const loader = HANDLER_MODULES[action]; - if (!loader) { - return res.status(500).json({ error: `No handler module for action: ${action}` }); + let client = null; + try { + client = getDb(); + } catch { + client = null; } - const mod = await loader(); - return mod.default(req, res); + const response = await dispatchLearningAction({ + request: toFetchRequest(req), + client, + user, + json, + }); + const payload = await response.json(); + return res.status(response.status).json(payload); } diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b45d3b31..8c481108 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -45,7 +45,7 @@ to prod. ``` src/ React SPA (pages, components, hooks, data, lib, adapters) api/ Legacy local handlers (.mjs) — kept for local dev parity -handlers/ Action handlers used by both api/ and functions/ +handlers/ Fetch-style action handlers used by dispatchLearningAction functions/api/ Cloudflare Pages Functions (production catch-all) shared/ Code shared between api/ and functions/ (db, lib, handlers, fixtures) scripts/ Content pipelines + env validation + deploy helpers diff --git a/docs/development/setup.md b/docs/development/setup.md index daed4f02..cac917b2 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -32,7 +32,7 @@ route set. The `api/*.mjs` handlers are dev/legacy only and are not deployed. | `/api/chat` | `vite-plugin-local-ai.js` streams CLIs | Not served (client still calls it) | | `/api/chats`, `/api/notes` | In-memory Vite stubs | Not served | | `/api/progress`, `/api/auth/*` | In-memory Vite stubs | Pages Function → D1 | -| `/api/learning?action=…` | Legacy `api/learning.mjs` → `handlers/` | Pages Function → `handlers/` (via `shared/`) | +| `/api/learning?action=…` | Legacy `api/learning.mjs` → `dispatchLearningAction` → Fetch `handlers/` | Pages Function → `dispatchLearningAction` → Fetch `handlers/` | | `/api/learning/reader`, `/api/ai` | (dev stubs / static) | Pages Function | `tag` is a `/api/learning?action=tag` action, not a top-level `/api/tag` diff --git a/docs/knowledge/learnings.md b/docs/knowledge/learnings.md index 83aae528..04821cf8 100644 --- a/docs/knowledge/learnings.md +++ b/docs/knowledge/learnings.md @@ -4,6 +4,13 @@ Reusable lessons that are not obvious from the code. Add new entries at the top with a date. One lesson per bullet; link to the code or ADR that exemplifies it. +## 2026-08 — Learning actions are Fetch handlers, not Express + +Production already authenticates in the Pages Function and +`dispatchLearningAction`. The Express `(req, res)` adapter was leftover from +Vercel — handlers now take `{ request, user, json }` and return `json(...)`. +Do not reintroduce a second Express dispatcher for `/api/learning`. + ## 2026-07 — Broad curriculum coverage needs a machine-readable contract Track names alone cannot prove that a broad learning taxonomy is actually diff --git a/handlers/activity.mjs b/handlers/activity.mjs index b1cc3a84..cc0976d0 100644 --- a/handlers/activity.mjs +++ b/handlers/activity.mjs @@ -1,7 +1,8 @@ +import { randomBytes } from 'node:crypto'; + +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; -import { randomBytes } from 'node:crypto'; let initialized = false; async function ensureInit() { @@ -11,15 +12,14 @@ async function ensureInit() { } } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'POST') { - const { kind, problemId, conceptIds, durationMs, payload } = req.body || {}; - if (!kind) return res.status(400).json({ error: 'kind required' }); + if (request.method === 'POST') { + const { kind, problemId, conceptIds, durationMs, payload } = await readJsonBody(request); + if (!kind) return json({ error: 'kind required' }, { status: 400 }); const id = randomBytes(16).toString('hex'); await db.execute({ sql: `INSERT INTO activity_log (id, user_id, kind, problem_id, concept_ids, duration_ms, payload) @@ -34,11 +34,11 @@ export default async function handler(req, res) { payload ? JSON.stringify(payload) : null, ], }); - return res.status(200).json({ id }); + return json({ id }); } - if (req.method === 'GET') { - const days = parseInt(req.query.days || '7', 10); + if (request.method === 'GET') { + const days = parseInt(new URL(request.url).searchParams.get('days') || '7', 10); const since = new Date(Date.now() - days * 86400000).toISOString(); const result = await db.execute({ sql: `SELECT id, kind, problem_id, concept_ids, duration_ms, payload, created_at @@ -54,8 +54,8 @@ export default async function handler(req, res) { payload: r.payload ? JSON.parse(r.payload) : null, createdAt: r.created_at, })); - return res.status(200).json({ activity: rows }); + return json({ activity: rows }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/artifacts.mjs b/handlers/artifacts.mjs index e9491ae8..17983b1e 100644 --- a/handlers/artifacts.mjs +++ b/handlers/artifacts.mjs @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { requireAuth } from '../api/auth/verify.mjs'; +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; @@ -23,25 +23,24 @@ function toEntry(row) { }; } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT * FROM user_artifacts WHERE user_id = ?', args: [user.id], }); const artifacts = {}; for (const row of r.rows) artifacts[row.artifact_id] = toEntry(row); - return res.status(200).json({ artifacts }); + return json({ artifacts }); } - if (req.method === 'POST') { - const { artifactId, status, url, path, notes, criteria } = req.body || {}; - if (!artifactId) return res.status(400).json({ error: 'artifactId required' }); + if (request.method === 'POST') { + const { artifactId, status, url, path, notes, criteria } = await readJsonBody(request); + if (!artifactId) return json({ error: 'artifactId required' }, { status: 400 }); await db.execute({ sql: `INSERT INTO user_artifacts (id, user_id, artifact_id, status, url, path, notes, criteria_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?) @@ -63,8 +62,8 @@ export default async function handler(req, res) { criteria ? JSON.stringify(criteria) : null, ], }); - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/concepts.mjs b/handlers/concepts.mjs index 8920a069..e016e538 100644 --- a/handlers/concepts.mjs +++ b/handlers/concepts.mjs @@ -1,8 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; -import { reviewConcept, masteryConfidence } from '../shared/lib/fsrs.mjs'; -import { randomBytes } from 'node:crypto'; +import { masteryConfidence, reviewConcept } from '../shared/lib/fsrs.mjs'; /** * Snake_case DB/FSRS row → the camelCase shape `useConcepts` expects. @@ -75,13 +76,12 @@ async function upsertMastery(db, userId, conceptId, row) { }); } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT * FROM concept_mastery WHERE user_id = ?', args: [user.id], @@ -91,22 +91,23 @@ export default async function handler(req, res) { for (const row of r.rows) { mastery[row.concept_id] = toClient(row, now); } - return res.status(200).json({ mastery }); + return json({ mastery }); } - if (req.method === 'POST') { - const { conceptId, rating } = req.body || {}; - if (!conceptId || !rating) return res.status(400).json({ error: 'conceptId, rating required' }); + if (request.method === 'POST') { + const { conceptId, rating } = await readJsonBody(request); + if (!conceptId || !rating) + return json({ error: 'conceptId, rating required' }, { status: 400 }); const prev = await getMastery(db, user.id, conceptId); const next = reviewConcept(prev, rating); await upsertMastery(db, user.id, conceptId, next); - return res.status(200).json({ mastery: toClient(next) }); + return json({ mastery: toClient(next) }); } - if (req.method === 'PUT') { + if (request.method === 'PUT') { // Bulk update from tagger: [{conceptId, rating}] - const { updates } = req.body || {}; - if (!Array.isArray(updates)) return res.status(400).json({ error: 'updates array required' }); + const { updates } = await readJsonBody(request); + if (!Array.isArray(updates)) return json({ error: 'updates array required' }, { status: 400 }); const results = []; for (const u of updates) { if (!u.conceptId || !u.rating) continue; @@ -115,8 +116,8 @@ export default async function handler(req, res) { await upsertMastery(db, user.id, u.conceptId, next); results.push({ conceptId: u.conceptId, mastery: toClient(next) }); } - return res.status(200).json({ results }); + return json({ results }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/critique.mjs b/handlers/critique.mjs index c7c04e50..77d06126 100644 --- a/handlers/critique.mjs +++ b/handlers/critique.mjs @@ -1,5 +1,6 @@ // AI Review Critic — grades the learner's recall/explanation answer against a // reference answer. BYOK only (no server-key fallback, so no auth needed). +import { readJsonBody } from '../shared/api/read-json.mjs'; import { generate, parseJSON } from '../shared/lib/ai.mjs'; const SYSTEM = `You grade an engineer's recall answer against a reference answer. @@ -71,15 +72,17 @@ export function validateSystemDesignResponse(value, systemDesignCase, stageAnswe return { dimensions: value.dimensions, verdict: value.verdict.trim() }; } -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); +export default async function handler({ request, json }) { + if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 }); - const { aiConfig, question, answer, expected, systemDesignCase, stageAnswers } = req.body || {}; + const { aiConfig, question, answer, expected, systemDesignCase, stageAnswers } = + await readJsonBody(request); const hasAI = aiConfig?.endpointUrl && aiConfig.apiKey && aiConfig.model; if (!hasAI) { - return res - .status(400) - .json({ error: 'Configure an AI provider in Settings to use the Review Critic.' }); + return json( + { error: 'Configure an AI provider in Settings to use the Review Critic.' }, + { status: 400 } + ); } if (systemDesignCase) { if ( @@ -89,9 +92,10 @@ export default async function handler(req, res) { !stageAnswers || typeof stageAnswers !== 'object' ) { - return res - .status(400) - .json({ error: 'valid systemDesignCase and stageAnswers are required' }); + return json( + { error: 'valid systemDesignCase and stageAnswers are required' }, + { status: 400 } + ); } const systemDesignPrompt = `Case and fixed rubric:\n${JSON.stringify(systemDesignCase)}\n\nLearner stage answers:\n${JSON.stringify(stageAnswers)}\n\nGrade now. JSON only.`; @@ -110,16 +114,17 @@ export default async function handler(req, res) { stageAnswers ); if (!validated) throw new Error('provider returned an invalid system-design critique'); - return res.status(200).json(validated); + return json(validated); } catch (err) { - return res - .status(502) - .json({ error: `AI request failed: ${err.message || 'unknown error'}` }); + return json( + { error: `AI request failed: ${err.message || 'unknown error'}` }, + { status: 502 } + ); } } if (!question || !answer) { - return res.status(400).json({ error: 'question and answer are required' }); + return json({ error: 'question and answer are required' }, { status: 400 }); } const prompt = `Question: @@ -142,8 +147,8 @@ Grade now. JSON only.`; prompt, maxTokens: 800, }); - return res.status(200).json(parseJSON(text)); + return json(parseJSON(text)); } catch (err) { - return res.status(502).json({ error: `AI request failed: ${err.message || 'unknown error'}` }); + return json({ error: `AI request failed: ${err.message || 'unknown error'}` }, { status: 502 }); } } diff --git a/handlers/drills.mjs b/handlers/drills.mjs index c3925fb2..0a096b50 100644 --- a/handlers/drills.mjs +++ b/handlers/drills.mjs @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { requireAuth } from '../api/auth/verify.mjs'; +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; @@ -12,13 +12,12 @@ async function ensureInit() { } } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT * FROM user_drills WHERE user_id = ?', args: [user.id], @@ -33,12 +32,12 @@ export default async function handler(req, res) { updatedAt: row.updated_at, }; } - return res.status(200).json({ drills }); + return json({ drills }); } - if (req.method === 'POST') { - const { drillId, status, lastCode } = req.body || {}; - if (!drillId) return res.status(400).json({ error: 'drillId required' }); + if (request.method === 'POST') { + const { drillId, status, lastCode } = await readJsonBody(request); + if (!drillId) return json({ error: 'drillId required' }, { status: 400 }); const now = new Date().toISOString(); // attempts increments on every save; status reflects the latest outcome. await db.execute({ @@ -69,8 +68,8 @@ export default async function handler(req, res) { JSON.stringify({ drillId, status: status || 'attempted' }), ], }); - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/elo.mjs b/handlers/elo.mjs index b1fc9b23..f4de593b 100644 --- a/handlers/elo.mjs +++ b/handlers/elo.mjs @@ -1,6 +1,6 @@ +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; let initialized = false; async function ensureInit() { @@ -12,32 +12,31 @@ async function ensureInit() { const EMPTY = { elo: {}, solves: {}, v: 2 }; -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT state_json FROM user_elo_state WHERE user_id = ?', args: [user.id], }); - if (!r.rows.length) return res.status(200).json({ state: EMPTY }); - return res.status(200).json({ state: JSON.parse(r.rows[0].state_json) }); + if (!r.rows.length) return json({ state: EMPTY }); + return json({ state: JSON.parse(r.rows[0].state_json) }); } - if (req.method === 'PUT') { - const { state } = req.body || {}; + if (request.method === 'PUT') { + const { state } = await readJsonBody(request); if (!state || typeof state !== 'object') - return res.status(400).json({ error: 'state required' }); + return json({ error: 'state required' }, { status: 400 }); await db.execute({ sql: `INSERT INTO user_elo_state (user_id, state_json, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET state_json = excluded.state_json, updated_at = datetime('now')`, args: [user.id, JSON.stringify({ ...EMPTY, ...state, v: 2 })], }); - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/feynman.mjs b/handlers/feynman.mjs index 245dd37a..912ea382 100644 --- a/handlers/feynman.mjs +++ b/handlers/feynman.mjs @@ -1,8 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; import { generate, parseJSON } from '../shared/lib/ai.mjs'; -import { randomBytes } from 'node:crypto'; let initialized = false; async function ensureInit() { @@ -50,17 +51,16 @@ Grading rubric: Use "again" for concepts they contradict, "hard" for shaky, "good" for solid, "easy" for nailed. Only emit ratings/gaps for concept_ids in the provided concept list. Treat the supplied simulation artifact as ground truth.`; -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 }); const { explanation, code, language, problem, problemId, conceptIds, artifact, aiConfig } = - req.body || {}; - if (!explanation) return res.status(400).json({ error: 'explanation required' }); + await readJsonBody(request); + if (!explanation) return json({ error: 'explanation required' }, { status: 400 }); const conceptList = (conceptIds || []).join(', ') || '(none tagged — infer from code)'; @@ -110,7 +110,7 @@ Grade now. Return JSON only.`; } if (parsed.grade != null) parsed.grade = Number(parsed.grade); } catch (e) { - return res.status(500).json({ error: `AI grading failed: ${e.message}` }); + return json({ error: `AI grading failed: ${e.message}` }, { status: 500 }); } const id = randomBytes(16).toString('hex'); @@ -129,5 +129,5 @@ Grade now. Return JSON only.`; ], }); - return res.status(200).json({ id, ...parsed }); + return json({ id, ...parsed }); } diff --git a/handlers/gaps.mjs b/handlers/gaps.mjs index 9041feec..35b1e37d 100644 --- a/handlers/gaps.mjs +++ b/handlers/gaps.mjs @@ -1,6 +1,7 @@ // AI Gap Analyzer — given the learner's mastery profile, suggest weak areas, // the next concepts to study, and an artifact to build. BYOK only (the client // must pass a complete aiConfig); no server-key fallback, so no auth needed. +import { readJsonBody } from '../shared/api/read-json.mjs'; import { generate, parseJSON } from '../shared/lib/ai.mjs'; const SYSTEM = `You are a learning coach for an engineer building toward AI search/infrastructure depth. @@ -18,15 +19,16 @@ Rules: - nextConcepts: 3-5 items, ordered by what to do first. - Be concrete and direct. No filler.`; -export default async function handler(req, res) { - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); +export default async function handler({ request, json }) { + if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 }); - const { aiConfig, profile, catalog } = req.body || {}; + const { aiConfig, profile, catalog } = await readJsonBody(request); const hasAI = aiConfig?.endpointUrl && aiConfig.apiKey && aiConfig.model; if (!hasAI) { - return res - .status(400) - .json({ error: 'Configure an AI provider in Settings to use the Gap Analyzer.' }); + return json( + { error: 'Configure an AI provider in Settings to use the Gap Analyzer.' }, + { status: 400 } + ); } const prompt = `Concept catalog (id: name [track]): @@ -49,8 +51,8 @@ Analyze now. JSON only.`; prompt, maxTokens: 900, }); - return res.status(200).json(parseJSON(text)); + return json(parseJSON(text)); } catch (err) { - return res.status(502).json({ error: `AI request failed: ${err.message || 'unknown error'}` }); + return json({ error: `AI request failed: ${err.message || 'unknown error'}` }, { status: 502 }); } } diff --git a/handlers/imported-reviews.mjs b/handlers/imported-reviews.mjs index 65925319..db768527 100644 --- a/handlers/imported-reviews.mjs +++ b/handlers/imported-reviews.mjs @@ -1,6 +1,6 @@ +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; import { deleteImportedDeck, listImportedReviews, @@ -15,29 +15,29 @@ async function ensureInit() { } } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const reviews = await listImportedReviews(db, user.id); - return res.status(200).json({ reviews }); + return json({ reviews }); } - if (req.method === 'POST') { - const { deckName, cards } = req.body || {}; + if (request.method === 'POST') { + const { deckName, cards } = await readJsonBody(request); const result = await upsertImportedReviews(db, user.id, { deckName, cards }); const reviews = await listImportedReviews(db, user.id); - return res.status(200).json({ ...result, reviews }); + return json({ ...result, reviews }); } - if (req.method === 'DELETE') { - const deckName = req.query.deck || req.body?.deckName; + if (request.method === 'DELETE') { + const body = await readJsonBody(request); + const deckName = new URL(request.url).searchParams.get('deck') || body?.deckName; const result = await deleteImportedDeck(db, user.id, deckName); - return res.status(200).json(result); + return json(result); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/learning-notes.mjs b/handlers/learning-notes.mjs index 5daeb9d9..3c9792fe 100644 --- a/handlers/learning-notes.mjs +++ b/handlers/learning-notes.mjs @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { requireAuth } from '../api/auth/verify.mjs'; +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; @@ -23,14 +23,15 @@ function toNote(row) { }; } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); + const query = new URL(request.url).searchParams; - if (req.method === 'GET') { - const { scope, refId } = req.query || {}; + if (request.method === 'GET') { + const scope = query.get('scope'); + const refId = query.get('refId'); let sql = 'SELECT * FROM user_learning_notes WHERE user_id = ?'; const args = [user.id]; if (scope) { @@ -43,12 +44,12 @@ export default async function handler(req, res) { } sql += ' ORDER BY updated_at DESC'; const r = await db.execute({ sql, args }); - return res.status(200).json({ notes: r.rows.map(toNote) }); + return json({ notes: r.rows.map(toNote) }); } - if (req.method === 'POST') { - const { id, scope, refId, title, body } = req.body || {}; - if (!scope || !body) return res.status(400).json({ error: 'scope, body required' }); + if (request.method === 'POST') { + const { id, scope, refId, title, body } = await readJsonBody(request); + if (!scope || !body) return json({ error: 'scope, body required' }, { status: 400 }); const noteId = id || randomBytes(16).toString('hex'); if (id) { await db.execute({ @@ -63,18 +64,19 @@ export default async function handler(req, res) { args: [noteId, user.id, scope, refId || null, title || null, body], }); } - return res.status(200).json({ id: noteId }); + return json({ id: noteId }); } - if (req.method === 'DELETE') { - const id = req.query?.id || req.body?.id; - if (!id) return res.status(400).json({ error: 'id required' }); + if (request.method === 'DELETE') { + const body = await readJsonBody(request); + const id = query.get('id') || body?.id; + if (!id) return json({ error: 'id required' }, { status: 400 }); await db.execute({ sql: 'DELETE FROM user_learning_notes WHERE id = ? AND user_id = ?', args: [id, user.id], }); - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/profile.mjs b/handlers/profile.mjs index 2b692dfe..bbecef7b 100644 --- a/handlers/profile.mjs +++ b/handlers/profile.mjs @@ -1,6 +1,6 @@ +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; let initialized = false; async function ensureInit() { @@ -30,26 +30,25 @@ const DEFAULT = { onboardingVersion: 4, }; -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT profile_json, updated_at FROM user_profile WHERE user_id = ?', args: [user.id], }); - if (!r.rows.length) return res.status(200).json({ profile: DEFAULT, updatedAt: null }); + if (!r.rows.length) return json({ profile: DEFAULT, updatedAt: null }); const profile = JSON.parse(r.rows[0].profile_json); - return res.status(200).json({ profile, updatedAt: r.rows[0].updated_at }); + return json({ profile, updatedAt: r.rows[0].updated_at }); } - if (req.method === 'PUT') { - const { profile } = req.body || {}; + if (request.method === 'PUT') { + const { profile } = await readJsonBody(request); if (!profile || typeof profile !== 'object') { - return res.status(400).json({ error: 'profile object required' }); + return json({ error: 'profile object required' }, { status: 400 }); } const merged = { ...DEFAULT, ...profile, updatedAt: new Date().toISOString() }; await db.execute({ @@ -57,8 +56,8 @@ export default async function handler(req, res) { ON CONFLICT(user_id) DO UPDATE SET profile_json = excluded.profile_json, updated_at = datetime('now')`, args: [user.id, JSON.stringify(merged)], }); - return res.status(200).json({ profile: merged }); + return json({ profile: merged }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/projects.mjs b/handlers/projects.mjs index 9d957175..381de37d 100644 --- a/handlers/projects.mjs +++ b/handlers/projects.mjs @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; -import { requireAuth } from '../api/auth/verify.mjs'; +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; @@ -12,13 +12,12 @@ async function ensureInit() { } } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT * FROM user_projects WHERE user_id = ?', args: [user.id], @@ -32,12 +31,12 @@ export default async function handler(req, res) { updatedAt: row.updated_at, }; } - return res.status(200).json({ projects }); + return json({ projects }); } - if (req.method === 'POST') { - const { projectId, status, nextAction, milestones } = req.body || {}; - if (!projectId) return res.status(400).json({ error: 'projectId required' }); + if (request.method === 'POST') { + const { projectId, status, nextAction, milestones } = await readJsonBody(request); + if (!projectId) return json({ error: 'projectId required' }, { status: 400 }); await db.execute({ sql: `INSERT INTO user_projects (id, user_id, project_id, status, next_action, milestones_json) VALUES (?, ?, ?, ?, ?, ?) @@ -55,8 +54,8 @@ export default async function handler(req, res) { milestones ? JSON.stringify(milestones) : null, ], }); - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/review-mastery.mjs b/handlers/review-mastery.mjs index 46bebfd1..83ea83e7 100644 --- a/handlers/review-mastery.mjs +++ b/handlers/review-mastery.mjs @@ -1,8 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; import { reviewConcept } from '../shared/lib/fsrs.mjs'; -import { randomBytes } from 'node:crypto'; let initialized = false; async function ensureInit() { @@ -66,13 +67,12 @@ function toClient(row) { }; } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT question_id, stability, difficulty, reps, lapses, state, last_review, due FROM review_question_mastery WHERE user_id = ?', args: [user.id], @@ -81,30 +81,30 @@ export default async function handler(req, res) { for (const row of r.rows) { mastery[row.question_id] = toClient(row); } - return res.status(200).json({ mastery }); + return json({ mastery }); } - if (req.method === 'POST') { - const { questionId, rating } = req.body || {}; + if (request.method === 'POST') { + const { questionId, rating } = await readJsonBody(request); if (!questionId || !rating) - return res.status(400).json({ error: 'questionId, rating required' }); + return json({ error: 'questionId, rating required' }, { status: 400 }); const prev = await getRow(db, user.id, questionId); const next = reviewConcept(prev, rating); await upsert(db, user.id, questionId, next); - return res.status(200).json({ mastery: toClient({ ...next, question_id: questionId }) }); + return json({ mastery: toClient({ ...next, question_id: questionId }) }); } - if (req.method === 'PUT') { - const { updates } = req.body || {}; - if (!Array.isArray(updates)) return res.status(400).json({ error: 'updates array required' }); + if (request.method === 'PUT') { + const { updates } = await readJsonBody(request); + if (!Array.isArray(updates)) return json({ error: 'updates array required' }, { status: 400 }); for (const u of updates) { if (!u.questionId || !u.rating) continue; const prev = await getRow(db, user.id, u.questionId); const next = reviewConcept(prev, u.rating); await upsert(db, user.id, u.questionId, next); } - return res.status(200).json({ ok: true }); + return json({ ok: true }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/handlers/tag.mjs b/handlers/tag.mjs index 1acf01f8..d3a3333f 100644 --- a/handlers/tag.mjs +++ b/handlers/tag.mjs @@ -1,4 +1,4 @@ -import { requireAuth } from '../api/auth/verify.mjs'; +import { readJsonBody } from '../shared/api/read-json.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; import { generate, parseJSON } from '../shared/lib/ai.mjs'; import { tagConcepts } from '../shared/lib/heuristics.mjs'; @@ -32,14 +32,12 @@ Rules: - "surface" = mentioned/imported, "working" = used correctly, "deep" = non-trivial application - Empty array if no concept clearly used`; -export default async function handler(req, res) { +export default async function handler({ request, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; - if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' }); + if (request.method !== 'POST') return json({ error: 'Method not allowed' }, { status: 405 }); - const { code, language, problem, aiConfig } = req.body || {}; - if (!code || code.length < 30) return res.status(200).json({ concepts: [] }); + const { code, language, problem, aiConfig } = await readJsonBody(request); + if (!code || code.length < 30) return json({ concepts: [] }); const concepts = loadConcepts(); const conceptList = concepts.map((c) => `${c.id}: ${c.name}`).join('\n'); @@ -62,10 +60,10 @@ Tag now. JSON only.`; try { const text = await generate({ ...aiConfig, system: SYSTEM, prompt, maxTokens: 600 }); const parsed = parseJSON(text); - return res.status(200).json({ ...parsed, generator: 'ai' }); + return json({ ...parsed, generator: 'ai' }); } catch { // Fall through to heuristic } } - return res.status(200).json({ concepts: tagConcepts(code, language), generator: 'heuristic' }); + return json({ concepts: tagConcepts(code, language), generator: 'heuristic' }); } diff --git a/handlers/understanding-check.mjs b/handlers/understanding-check.mjs index c9ad0f54..a0150a9a 100644 --- a/handlers/understanding-check.mjs +++ b/handlers/understanding-check.mjs @@ -9,6 +9,7 @@ // path uses generate() which falls back to env vars; production CF Pages // mirrors this in functions/api/[[path]].js with BYOK only. +import { readJsonBody } from '../shared/api/read-json.mjs'; import { generate, parseJSON } from '../shared/lib/ai.mjs'; const QUIZ_SYSTEM = `You write open-ended comprehension questions that test whether a reader has internalised a learning doc. @@ -70,14 +71,15 @@ function truncate(s, n) { return s.length > n ? `${s.slice(0, n)}\n…[truncated]` : s; } -export default async function handler(req, res) { - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); +export default async function handler({ request, json }) { + if (request.method !== 'POST') { + return json({ error: 'Method not allowed' }, { status: 405 }); } - const { op, docTitle, docContent, questions, answers, explanation, aiConfig } = req.body || {}; + const { op, docTitle, docContent, questions, answers, explanation, aiConfig } = + await readJsonBody(request); if (!op) - return res.status(400).json({ error: 'op required: quiz | grade-quiz | grade-explanation' }); - if (!docContent) return res.status(400).json({ error: 'docContent required' }); + return json({ error: 'op required: quiz | grade-quiz | grade-explanation' }, { status: 400 }); + if (!docContent) return json({ error: 'docContent required' }, { status: 400 }); const docExcerpt = truncate(docContent, 8000); const title = docTitle || '(untitled doc)'; @@ -91,9 +93,10 @@ export default async function handler(req, res) { maxTokens = 900; } else if (op === 'grade-quiz') { if (!Array.isArray(questions) || !Array.isArray(answers)) { - return res - .status(400) - .json({ error: 'questions and answers arrays required for grade-quiz' }); + return json( + { error: 'questions and answers arrays required for grade-quiz' }, + { status: 400 } + ); } const qa = questions .map((q, i) => `Q${i + 1}: ${q}\nA${i + 1}: ${truncate(answers[i] || '(blank)', 1500)}`) @@ -103,22 +106,22 @@ export default async function handler(req, res) { maxTokens = 1800; } else if (op === 'grade-explanation') { if (!explanation || explanation.trim().length < 30) { - return res.status(400).json({ error: 'explanation required (at least 30 chars)' }); + return json({ error: 'explanation required (at least 30 chars)' }, { status: 400 }); } system = GRADE_EXPLANATION_SYSTEM; prompt = `Doc title: ${title}\n\nDoc content:\n"""\n${docExcerpt}\n"""\n\nReader's explanation:\n"""\n${truncate(explanation, 4000)}\n"""\n\nGrade now. JSON only.`; maxTokens = 1200; } else { - return res.status(400).json({ error: `unknown op: ${op}` }); + return json({ error: `unknown op: ${op}` }, { status: 400 }); } const text = await generate({ ...(aiConfig || {}), system, prompt, maxTokens }); const parsed = parseJSON(text); if (!parsed || typeof parsed !== 'object') { - return res.status(502).json({ error: 'AI returned non-object' }); + return json({ error: 'AI returned non-object' }, { status: 502 }); } - return res.status(200).json(parsed); + return json(parsed); } catch (e) { - return res.status(500).json({ error: `AI call failed: ${e.message}` }); + return json({ error: `AI call failed: ${e.message}` }, { status: 500 }); } } diff --git a/handlers/weekly.mjs b/handlers/weekly.mjs index c5d09b0e..f6bb375d 100644 --- a/handlers/weekly.mjs +++ b/handlers/weekly.mjs @@ -1,10 +1,11 @@ +import { randomBytes } from 'node:crypto'; + +import { readJsonBody } from '../shared/api/read-json.mjs'; import { getDb } from '../shared/db/client.mjs'; import { initDatabase } from '../shared/db/schema.mjs'; -import { requireAuth } from '../api/auth/verify.mjs'; -import { masteryConfidence } from '../shared/lib/fsrs.mjs'; import { generate } from '../shared/lib/ai.mjs'; +import { masteryConfidence } from '../shared/lib/fsrs.mjs'; import { buildWeeklyReport } from '../shared/lib/heuristics.mjs'; -import { randomBytes } from 'node:crypto'; import conceptsData from '../src/data/concepts.json' with { type: 'json' }; @@ -39,20 +40,19 @@ function weekStart(date = new Date()) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), diff)).toISOString().slice(0, 10); } -export default async function handler(req, res) { +export default async function handler({ request, user, json }) { await ensureInit(); - const user = await requireAuth(req, res); - if (!user) return; + if (!user) return json({ error: 'Unauthorized' }, { status: 401 }); const db = getDb(); const ws = weekStart(); - if (req.method === 'GET') { + if (request.method === 'GET') { const r = await db.execute({ sql: 'SELECT report_md, stats_json, created_at FROM weekly_review WHERE user_id = ? ORDER BY week_start DESC LIMIT 1', args: [user.id], }); - if (r.rows.length === 0) return res.status(200).json({ review: null }); - return res.status(200).json({ + if (r.rows.length === 0) return json({ review: null }); + return json({ review: { reportMd: r.rows[0].report_md, stats: r.rows[0].stats_json ? JSON.parse(r.rows[0].stats_json) : null, @@ -61,8 +61,8 @@ export default async function handler(req, res) { }); } - if (req.method === 'POST') { - const { aiConfig } = req.body || {}; + if (request.method === 'POST') { + const { aiConfig } = await readJsonBody(request); const activity = await db.execute({ sql: `SELECT kind, concept_ids, duration_ms, payload, created_at FROM activity_log @@ -166,10 +166,10 @@ Write the review now.`; args: [id, user.id, ws, report, JSON.stringify(finalStats)], }); - return res.status(200).json({ + return json({ review: { reportMd: report, stats: finalStats, createdAt: new Date().toISOString() }, }); } - return res.status(405).json({ error: 'Method not allowed' }); + return json({ error: 'Method not allowed' }, { status: 405 }); } diff --git a/shared/api/express-bridge.mjs b/shared/api/express-bridge.mjs deleted file mode 100644 index 26665545..00000000 --- a/shared/api/express-bridge.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { setRequestDb } from '../db/client.mjs'; - -/** - * Run an Express-style handler (req, res) inside a Fetch/worker context. - */ -export async function runExpressHandler(handler, ctx) { - const { request, client, user } = ctx; - const url = new URL(request.url); - - let body = {}; - const contentType = request.headers.get('content-type') || ''; - if (request.method !== 'GET' && request.method !== 'HEAD' && contentType.includes('json')) { - try { - body = await request.json(); - } catch { - body = {}; - } - } - - const req = { - method: request.method, - query: Object.fromEntries(url.searchParams.entries()), - body, - headers: { - authorization: request.headers.get('authorization') || undefined, - cookie: request.headers.get('cookie') || undefined, - }, - _authenticatedUser: user || null, - }; - - let statusCode = 200; - let payload = null; - let settled = false; - - const res = { - status(code) { - statusCode = code; - return this; - }, - json(data) { - payload = data; - settled = true; - }, - end() { - settled = true; - }, - }; - - setRequestDb(client); - await handler(req, res); - if (!settled) { - statusCode = 500; - payload = { error: 'Handler did not respond' }; - } - return { status: statusCode, body: payload }; -} diff --git a/shared/api/learning-registry.mjs b/shared/api/learning-registry.mjs index 99e8276f..4efa76b7 100644 --- a/shared/api/learning-registry.mjs +++ b/shared/api/learning-registry.mjs @@ -1,6 +1,7 @@ /** * Canonical learning API surface — single source of truth for action names. - * Local (api/learning.mjs) and production (functions/api) must stay in sync. + * Local (api/learning.mjs) and production (functions/api) dispatch the same + * Fetch handlers via dispatchLearningAction. */ /** BYOK / heuristic — no user auth required. */ @@ -31,7 +32,7 @@ export const AUTH_ACTIONS = [ export const LEARNING_ACTIONS = [...new Set([...PUBLIC_ACTIONS, ...AUTH_ACTIONS])].sort(); -/** Actions routed through handlers/*.mjs (Express-style). */ +/** Actions routed through handlers/*.mjs (Fetch-style). */ export const HANDLER_MODULES = { activity: () => import('../../handlers/activity.mjs'), concepts: () => import('../../handlers/concepts.mjs'), diff --git a/shared/api/parity.test.mjs b/shared/api/parity.test.mjs index c5be79aa..a10309b7 100644 --- a/shared/api/parity.test.mjs +++ b/shared/api/parity.test.mjs @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { HANDLER_MODULES, LEARNING_ACTIONS } from './learning-registry.mjs'; @@ -35,9 +35,40 @@ describe('learning API parity', () => { expect(readFileSync(join(ROOT, 'vite.config.js'), 'utf8')).toContain('localAi()'); }); - it('local learning.mjs uses registry', () => { + it('local learning.mjs wraps dispatchLearningAction', () => { const src = readFileSync(join(ROOT, 'api/learning.mjs'), 'utf8'); - expect(src).toContain('learning-registry.mjs'); + expect(src).toContain('dispatchLearningAction'); + expect(src).not.toContain('mod.default(req, res)'); expect(src).not.toContain('daily.mjs'); }); + + it('dispatchLearningAction does not import the Express bridge', async () => { + const src = readFileSync(join(ROOT, 'shared/api/worker-learning.mjs'), 'utf8'); + expect(src).toContain('dispatchLearningAction'); + expect(src).not.toContain('express-bridge'); + expect(src).not.toContain('runExpressHandler'); + expect(existsSync(join(ROOT, 'shared/api/express-bridge.mjs'))).toBe(false); + const { dispatchLearningAction } = await import('./worker-learning.mjs'); + expect(dispatchLearningAction).toBeTypeOf('function'); + }); + + it('dispatchLearningAction rejects unknown actions and unauthenticated auth actions', async () => { + const { dispatchLearningAction } = await import('./worker-learning.mjs'); + const json = (body, init = {}) => + new Response(JSON.stringify(body), { status: init.status ?? 200 }); + const unknown = await dispatchLearningAction({ + request: new Request('http://localhost/api/learning?action=nope'), + client: null, + user: null, + json, + }); + expect(unknown.status).toBe(400); + const unauth = await dispatchLearningAction({ + request: new Request('http://localhost/api/learning?action=activity'), + client: null, + user: null, + json, + }); + expect(unauth.status).toBe(401); + }); }); diff --git a/shared/api/read-json.mjs b/shared/api/read-json.mjs new file mode 100644 index 00000000..fec8b320 --- /dev/null +++ b/shared/api/read-json.mjs @@ -0,0 +1,13 @@ +/** + * Parse a JSON request body. GET/HEAD and non-JSON content types yield {}. + */ +export async function readJsonBody(request) { + if (request.method === 'GET' || request.method === 'HEAD') return {}; + const contentType = request.headers.get('content-type') || ''; + if (!contentType.includes('json')) return {}; + try { + return await request.json(); + } catch { + return {}; + } +} diff --git a/shared/api/worker-learning.mjs b/shared/api/worker-learning.mjs index af9ff4e1..836ff62b 100644 --- a/shared/api/worker-learning.mjs +++ b/shared/api/worker-learning.mjs @@ -1,5 +1,5 @@ +import { setRequestDb } from '../db/client.mjs'; import { HANDLER_MODULES, LEARNING_ACTIONS } from './learning-registry.mjs'; -import { runExpressHandler } from './express-bridge.mjs'; const PUBLIC_NO_AUTH = new Set(['gaps', 'critique', 'understanding', 'tag']); @@ -25,6 +25,10 @@ export async function dispatchLearningAction(ctx) { const mod = await loader(); const handler = mod.default; - const { status, body } = await runExpressHandler(handler, { request, client, user }); - return json(body ?? {}, { status }); + setRequestDb(client); + const response = await handler({ request, user, json }); + if (!(response instanceof Response)) { + return json({ error: 'Handler did not respond' }, { status: 500 }); + } + return response; }