// Studio Notebook — bms-backbone server (Postgres + Prisma + OTM-account auth) import { createServer } from "node:http"; import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { join, dirname, extname } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID, createHmac, createHash, timingSafeEqual } from "node:crypto"; import { PrismaClient } from "@prisma/client"; import bcrypt from "bcryptjs"; import { APP_VERSION } from "./lib/version.mjs"; const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA = process.env.DATA_DIR || join(ROOT, "data"); const PHOTOS = join(DATA, "photos"); const FILES = join(DATA, "files"); mkdirSync(PHOTOS, { recursive: true }); mkdirSync(FILES, { recursive: true }); const db = new PrismaClient(); const PORT = Number(process.env.PORT || 3000); // Session-signing key: explicit env wins; else derive from the provision-injected // OPERATOR_SHARED_SECRET (per-slug, known only to OTM and this container — // domain-separated so the two uses can't collide). The old DATABASE_URL fallback // was derivable by anyone who ever saw the connection string; it survives ONLY // for unmanaged local dev — a managed instance with neither secret must not boot. const OP_SECRET = (process.env.OPERATOR_SHARED_SECRET || "").trim(); const SESSION_SECRET = process.env.SESSION_SECRET || (OP_SECRET ? createHmac("sha256", OP_SECRET).update("bms-session").digest("hex") : null) || (process.env.OTM_MANAGED === "true" ? (() => { throw new Error("managed instance without SESSION_SECRET/OPERATOR_SHARED_SECRET — refusing a derivable session key"); })() : createHash("sha256").update("bms:" + (process.env.DATABASE_URL || "dev")).digest("hex")); const SESSION_DAYS = 30; const OPERATOR_SESSION_MS = 3600e3; // impersonation lives 1h (contract), not 30 days // ── helpers ────────────────────────────────────────────────────── const fmtDate = (d) => { if (!d) return null; const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; // session cookie: base64(payload).hmac const sign = (payload) => { const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); const mac = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url"); return `${body}.${mac}`; }; const verify = (token) => { if (!token || !token.includes(".")) return null; const [body, mac] = token.split("."); const expect = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url"); try { if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expect))) return null; const p = JSON.parse(Buffer.from(body, "base64url").toString()); return p.exp > Date.now() ? p : null; } catch { return null; } }; const cookieOf = (req) => Object.fromEntries((req.headers.cookie || "").split(";").map((c) => c.trim().split("=").map(decodeURIComponent)).filter((p) => p[0])); // Compact HS256 JWS verify (signature + alg allowlist only — claim rules are // per-flow). Signed with the UTF-8 bytes of the shared secret string. const verifyJws = (token, secret) => { const parts = String(token).split("."); if (parts.length !== 3) return null; const expect = createHmac("sha256", secret).update(`${parts[0]}.${parts[1]}`).digest("base64url"); try { if (parts[2].length !== expect.length || !timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expect))) return null; const head = JSON.parse(Buffer.from(parts[0], "base64url").toString()); if (head.alg !== "HS256") return null; // no alg:none / RS256 confusion return JSON.parse(Buffer.from(parts[1], "base64url").toString()); } catch { return null; } }; // OTM's operator-magic token. OPERATOR_SHARED_SECRET also signs handoff and // module-sync tokens, which carry an `action` claim — refuse those so a // non-login token can never become a session (flow separation is OUR job). const verifyOtmToken = (token, secret) => { const p = verifyJws(token, secret); if (!p || p.action) return null; if (typeof p.exp !== "number" || p.exp < Math.floor(Date.now() / 1000)) return null; if (!p.slug || !p.operatorEmail || !p.jti) return null; // slug is bound by the signature, not compared return p; }; // OTM account-SSO ticket — stricter by contract (sso-ticket.ts): exp MANDATORY, // audience compared explicitly, sub/role/email required. const verifySsoTicket = (token, secret, aud) => { const p = verifyJws(token, secret); if (!p) return null; if (typeof p.exp !== "number" || Math.floor(Date.now() / 1000) >= p.exp) return null; for (const k of ["jti", "sub", "aud", "role", "email"]) if (typeof p[k] !== "string" || !p[k]) return null; if (p.aud !== aud) return null; return p; }; // ── serializers (keep the frontend's field names) ──────────────── const sNote = (n) => ({ id: n.id, text: n.text, tags: n.tags, created: fmtDate(n.created) }); const sTodo = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, started: t.started ? 1 : 0, parent_id: t.parentId, created: fmtDate(t.created) }); const sShopping = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, created: fmtDate(t.created) }); const sGlaze = (g) => ({ id: g.id, name: g.name, kind: g.kind, cone: g.cone, atmosphere: g.atmosphere, surface: g.surface, source: g.source, sg: g.sg, swatch: g.swatch, created: fmtDate(g.created) }); const sMat = (m) => ({ id: m.id, glaze_id: m.glazeId, name: m.name, pct: m.pct, addition: m.addition ? 1 : 0 }); const sJournal = (j) => ({ id: j.id, glaze_id: j.glazeId, text: j.text, created: fmtDate(j.created) }); const sFiring = (f) => ({ id: f.id, type: f.type, cone: f.cone, schedule: f.schedule, status: f.status, result_notes: f.resultNotes, started: fmtDate(f.started), unloaded: fmtDate(f.unloaded) }); const sItem = (i) => ({ id: i.id, firing_id: i.firingId, piece: i.piece, clay: i.clay, glaze: i.glaze, rating: i.rating, note: i.note }); const sPhoto = (p) => ({ id: p.id, entity: p.entity, entity_id: p.entityId, filename: p.filename, created: fmtDate(p.created) }); const sFile = (f) => ({ id: f.id, entity: f.entity, entity_id: f.entityId, stored: f.stored, name: f.name, tag: f.tag, size: f.size, created: fmtDate(f.created) }); const sProduct = (p) => ({ id: p.id, name: p.name, kind: p.kind, category: p.category, price: p.price, cost: p.cost, qty: p.qty, status: p.status, notes: p.notes, created: fmtDate(p.created) }); const sSale = (s) => ({ id: s.id, product_id: s.productId, qty: s.qty, price: s.price, cost: s.cost, created: fmtDate(s.created) }); const sCourse = (c) => ({ id: c.id, title: c.title, subject: c.subject, source: c.source, url: c.url, status: c.status, notes: c.notes, created: fmtDate(c.created), page_count: c._count?.pages ?? 0 }); const sPage = (p) => ({ id: p.id, course_id: p.courseId, title: p.title, text: p.text, created: fmtDate(p.created), updated: fmtDate(p.updated) }); const sIdea = (i) => ({ id: i.id, text: i.text, category: i.category, medium: i.medium, status: i.status, created: fmtDate(i.created) }); const sSupplier = (s) => ({ id: s.id, name: s.name, kind: s.kind, distance: s.distance, url: s.url, notes: s.notes, search_url: s.searchUrl, created: fmtDate(s.created) }); // ── the "plays well with" evidence table (unload ratings only) ─── async function compatibility(glazeName) { const rows = await db.firingItem.findMany({ where: { glaze: glazeName, NOT: { rating: "" }, clay: { not: "" }, firing: { status: "unloaded" } }, }); const grouped = {}; for (const r of rows) { const key = `${r.clay}\u0000${r.rating}`; grouped[key] ??= { clay: r.clay, rating: r.rating, n: 0, notes: [] }; grouped[key].n++; if (r.note) grouped[key].notes.push(r.note); } return Object.values(grouped) .map((g) => ({ clay: g.clay, rating: g.rating, n: g.n, notes: g.notes.join(" · ") || null })) .sort((a, b) => a.clay.localeCompare(b.clay) || b.n - a.n); } // ── OTM integrations (suggestions → Gitea, self-update) ────────── // All three env vars or nothing — a half-configured instance must not half-work. const giteaConfig = () => { const [baseUrl, token, repo] = [process.env.GITEA_URL, process.env.GITEA_TOKEN, process.env.GITEA_REPO]; return baseUrl && token && repo ? { baseUrl, token, repo } : null; }; // same three-slot shape as @otm/account-panel's suggestionIssueFooter, so // issues on the repo read like every other consumer's const suggestionFooter = (name, email, label) => `\n\n---\n_Filed by ${name}${email ? ` (${email})` : ""}${label ? ` · ${label}` : ""}_`; async function fileSuggestion(text, actorEmail) { const cfg = giteaConfig(); if (!cfg) return null; const trimmed = String(text).trim().slice(0, 8000); // cap what we forward under OTM's token const first = trimmed.split("\n")[0].trim() || "Suggestion"; const title = first.length > 80 ? first.slice(0, 79) + "…" : first; const name = actorEmail ? actorEmail.split("@")[0] : "the studio"; const body = `${trimmed}${suggestionFooter(name, actorEmail || "", `Studio Notebook ${APP_VERSION}`)}`; const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/api/v1/repos/${cfg.repo}/issues`, { method: "POST", headers: { authorization: `token ${cfg.token}`, "content-type": "application/json" }, body: JSON.stringify({ title, body }), signal: AbortSignal.timeout(10_000), // a hung Gitea must not defeat the note fallback }); if (!res.ok) throw new Error(`Gitea ${res.status}: ${(await res.text()).slice(0, 300)}`); return res.json(); } // self-update credentials: a fresh ts/sig per request (OTM's window is ±2 min) const otmCreds = () => { const base = (process.env.OTM_API_BASE || "").replace(/\/+$/, ""); const serviceId = process.env.OTM_SERVICE_ID; const secret = (process.env.OPERATOR_SHARED_SECRET || "").trim(); if (!base || !serviceId || !secret) return null; const ts = Date.now().toString(); return { base, serviceId, ts, sig: createHmac("sha256", secret).update(`${serviceId}.${ts}`).digest("base64url") }; }; // Pick OTM's response fields EXPLICITLY — the response layer acts on _-prefixed // control keys (_setSession/_redirect), and a spread would hand that namespace // to whatever the upstream body contains. Unreachable OTM → 502. async function otmProxy(url, init = {}) { try { const res = await fetch(url, { ...init, signal: AbortSignal.timeout(10_000) }); const j = await res.json().catch(() => ({})); return { _status: res.status, ok: j.ok, latest: j.latest, status: j.status, error: j.error, message: j.message }; } catch (e) { return { _status: 502, error: String(e.message || "otm_unreachable") }; } } // ── routes ─────────────────────────────────────────────────────── const routes = { "GET /api/health": async () => ({ ok: true, version: APP_VERSION }), "POST /api/login": async (b) => { const user = await db.user.findUnique({ where: { email: String(b.email || "").toLowerCase().trim() } }); if (!user || !(await bcrypt.compare(String(b.password || ""), user.passwordHash))) return { error: "wrong email or password" }; return { _setSession: { email: user.email, exp: Date.now() + SESSION_DAYS * 86400e3 }, ok: true }; }, "POST /api/logout": async () => ({ _clearSession: true, ok: true }), // OTM "Log in as admin" — a 60s single-use token from the control plane. // Every failure is a 302 to /login (never a body, never a 4xx) — the operator reads the reason. "GET /api/auth/operator-magic": async (_b, q) => { const fail = (reason) => ({ _redirect: reason ? `/login?reason=${reason}` : "/login" }); const token = q.get("token"); if (!token) return fail(""); const secret = (process.env.OPERATOR_SHARED_SECRET || "").trim(); if (!secret) return fail("not_enabled"); const claims = verifyOtmToken(token, secret); if (!claims) return fail("invalid_token"); try { await db.operatorMagicConsumed.create({ data: { jti: claims.jti, operatorEmail: claims.operatorEmail } }); } // claim it BEFORE any session catch (e) { return fail(e.code === "P2002" ? "link_already_used" : "server_error"); } if (Math.random() < 0.01) // best-effort prune, nothing depends on it void db.operatorMagicConsumed.deleteMany({ where: { consumedAt: { lt: new Date(Date.now() - 86400e3) } } }).catch(() => {}); const owner = await db.user.findFirst({ where: { role: "OWNER" }, orderBy: { id: "asc" } }); if (!owner) return fail("no_admin"); console.log(`operator-magic: ${claims.operatorEmail} signed in as ${owner.email} (jti ${claims.jti})`); return { _setSession: { email: owner.email, operator: claims.operatorEmail, operatorId: claims.operatorId || null, exp: Date.now() + OPERATOR_SESSION_MS }, _redirect: "/" }; }, // OTM account SSO — the /account tile's door. Ticket carries the OTM identity // (which may not be a local email): exact-email match first, else the first // user holding the ticket's role — the site template's actor mapping. "GET /api/auth/otm-sso": async (_b, q) => { const fail = (reason) => ({ _redirect: `/login?reason=${reason}` }); const secret = (process.env.OTM_SSO_SECRET || "").trim(); const audience = process.env.OTM_SSO_AUDIENCE || ""; if (!secret || !audience) return fail("not_enabled"); const p = verifySsoTicket(q.get("token"), secret, audience); if (!p) return fail("invalid_token"); try { await db.operatorMagicConsumed.create({ data: { jti: p.jti, operatorEmail: p.email } }); } // shared single-use table catch (e) { return fail(e.code === "P2002" ? "link_already_used" : "server_error"); } let user = await db.user.findFirst({ where: { email: { equals: p.email, mode: "insensitive" } } }); if (!user) user = await db.user.findFirst({ where: { role: p.role }, orderBy: { id: "asc" } }); if (!user) return fail("no_account"); const next = p.next && p.next.startsWith("/") && !p.next.startsWith("//") ? p.next : "/"; return { _setSession: { email: user.email, exp: Date.now() + SESSION_DAYS * 86400e3 }, _redirect: next }; }, "GET /api/me": async (_b, _q, session) => ({ email: session.email, operator: session.operator ?? null }), "GET /api/summary": async () => ({ todos: (await db.todo.findMany({ orderBy: { id: "desc" }, take: 60 })).map(sTodo), shopping: (await db.shopping.findMany({ orderBy: [{ done: "asc" }, { id: "desc" }], take: 12 })).map(sShopping), notes: (await db.note.findMany({ orderBy: { id: "desc" }, take: 6 })).map(sNote), activeFiring: await db.firing.findFirst({ where: { status: "firing" }, orderBy: { id: "desc" } }).then((f) => f ? sFiring(f) : null), lastUnload: await db.firing.findFirst({ where: { status: "unloaded" }, orderBy: { unloaded: "desc" } }).then((f) => f ? sFiring(f) : null), }), "POST /api/jot": async (b, _q, session) => { const kind = b.kind || "note"; const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean); if (kind === "todo") { const parent = await db.todo.create({ data: { text: lines[0] } }); for (const sub of lines.slice(1)) await db.todo.create({ data: { text: sub, parentId: parent.id } }); return { ok: true }; } if (kind === "shopping") { for (const l of lines) await db.shopping.create({ data: { text: l } }); return { ok: true }; } if (kind === "idea") { // → mind dump, unsorted await db.idea.create({ data: { text: b.text } }); return { ok: true }; } if (kind === "suggestion") { try { const issue = await fileSuggestion(b.text, session?.email || ""); if (issue) { await db.note.create({ data: { text: `#${issue.number} — ${b.text}`, tags: "suggestion" } }); // her own record of what she sent return { ok: true, issue: issue.number, url: issue.html_url }; } } catch (e) { console.error("suggestion → Gitea failed:", e.message); } await db.note.create({ data: { text: b.text, tags: "suggestion" } }); // a jot is never lost return { ok: true, fallback: true }; } await db.note.create({ data: { text: b.text, tags: b.tags || (kind !== "note" ? kind : "") } }); return { ok: true }; }, "POST /api/todos/toggle": async (b) => { const t = await db.todo.findUnique({ where: { id: b.id } }); if (t) await db.todo.update({ where: { id: b.id }, data: t.done ? { done: false } : { done: true, started: false } }); return { ok: true }; }, "POST /api/todos/start": async (b) => { const t = await db.todo.findUnique({ where: { id: b.id } }); if (t) await db.todo.update({ where: { id: b.id }, data: { started: !t.started } }); return { ok: true }; }, "POST /api/todos/add": async (b) => { const t = await db.todo.create({ data: { text: b.text, parentId: b.parent_id ?? null } }); return { id: t.id }; }, "POST /api/todos/delete": async (b) => { await db.todo.deleteMany({ where: { parentId: b.id } }); await db.todo.deleteMany({ where: { id: b.id } }); return { ok: true }; }, "POST /api/shopping/toggle": async (b) => { const t = await db.shopping.findUnique({ where: { id: b.id } }); if (t) await db.shopping.update({ where: { id: b.id }, data: { done: !t.done } }); return { ok: true }; }, "GET /api/notes": async () => (await db.note.findMany({ orderBy: { id: "desc" } })).map(sNote), "GET /api/glazes": async () => (await db.glaze.findMany({ orderBy: { name: "asc" }, include: { materials: { orderBy: [{ addition: "asc" }, { pct: "desc" }] } } })) .map((g) => ({ ...sGlaze(g), materials: g.materials.map(sMat) })), "GET /api/glaze": async (_b, q) => { const g = await db.glaze.findUnique({ where: { id: Number(q.get("id")) }, include: { materials: { orderBy: [{ addition: "asc" }, { pct: "desc" }] }, journal: { orderBy: { id: "desc" } } } }); if (!g) return null; return { ...sGlaze(g), materials: g.materials.map(sMat), journal: g.journal.map(sJournal), compatibility: await compatibility(g.name), photos: (await db.photo.findMany({ where: { entity: "glaze", entityId: g.id } })).map(sPhoto) }; }, "POST /api/glazes": async (b) => { const g = await db.glaze.create({ data: { name: b.name, kind: b.kind || "glaze", cone: b.cone || "", atmosphere: b.atmosphere || "", surface: b.surface || "", source: b.source || "mine", sg: b.sg || "", swatch: b.swatch || "", materials: { create: (b.materials || []).map((m) => ({ name: m.name, pct: m.pct, addition: !!m.addition })) }, } }); return { id: g.id }; }, "POST /api/glaze/journal": async (b) => { await db.glazeJournal.create({ data: { glazeId: b.glaze_id, text: b.text } }); return { ok: true }; }, "GET /api/firings": async () => (await db.firing.findMany({ orderBy: { id: "desc" }, include: { items: true } })) .map((f) => ({ ...sFiring(f), items: f.items.map(sItem) })), "POST /api/firings": async (b) => { const f = await db.firing.create({ data: { type: b.type || "glaze", cone: b.cone || "∆6", schedule: b.schedule || "", items: { create: (b.items || []).map((it) => ({ piece: it.piece, clay: it.clay || "", glaze: it.glaze || "" })) } } }); return { id: f.id }; }, "POST /api/firing/items": async (b) => { await db.firingItem.create({ data: { firingId: b.firing_id, piece: b.piece, clay: b.clay || "", glaze: b.glaze || "" } }); return { ok: true }; }, "POST /api/firing/rate": async (b) => { await db.firingItem.update({ where: { id: b.id }, data: { rating: b.rating, note: b.note || "" } }); return { ok: true }; }, "POST /api/firing/unload": async (b) => { await db.firing.update({ where: { id: b.id }, data: { status: "unloaded", unloaded: new Date(), resultNotes: b.result_notes || "" } }); return { ok: true }; }, "GET /api/products": async () => { const sales = await db.sale.findMany(); const monthStart = new Date(); monthStart.setDate(1); monthStart.setHours(0, 0, 0, 0); return { products: (await db.product.findMany({ orderBy: [{ kind: "asc" }, { category: "asc" }, { name: "asc" }] })).map(sProduct), money: { revenue: sales.reduce((a, s) => a + s.qty * s.price, 0), cogs: sales.reduce((a, s) => a + s.qty * s.cost, 0), monthRevenue: sales.filter((s) => s.created >= monthStart).reduce((a, s) => a + s.qty * s.price, 0), salesCount: sales.reduce((a, s) => a + s.qty, 0), }, }; }, "GET /api/product": async (_b, q) => { const p = await db.product.findUnique({ where: { id: Number(q.get("id")) }, include: { sales: { orderBy: { id: "desc" }, take: 10 } } }); if (!p) return null; return { ...sProduct(p), sales: p.sales.map(sSale), photos: (await db.photo.findMany({ where: { entity: "product", entityId: p.id } })).map(sPhoto), files: (await db.fileAsset.findMany({ where: { entity: "product", entityId: p.id }, orderBy: { id: "desc" } })).map(sFile) }; }, "POST /api/products": async (b) => { const p = await db.product.create({ data: { name: b.name, kind: b.kind || "core line", category: b.category || "", price: Number(b.price) || 0, cost: Number(b.cost) || 0, qty: Number(b.qty) || 0 } }); return { id: p.id }; }, "POST /api/products/notes": async (b) => { await db.product.update({ where: { id: b.id }, data: { notes: b.notes } }); return { ok: true }; }, "POST /api/products/qty": async (b) => { const p = await db.product.findUnique({ where: { id: b.id } }); if (p) await db.product.update({ where: { id: b.id }, data: { qty: Math.max(0, p.qty + b.delta) } }); return { ok: true }; }, "POST /api/products/status": async (b) => { await db.product.update({ where: { id: b.id }, data: { status: b.status } }); return { ok: true }; }, "POST /api/sales": async (b) => { const p = await db.product.findUnique({ where: { id: b.product_id } }); if (!p) return { error: "no such product" }; await db.sale.create({ data: { productId: p.id, qty: b.qty || 1, price: b.price ?? p.price, cost: p.cost } }); if (p.kind === "core line" || p.kind === "tiles") await db.product.update({ where: { id: p.id }, data: { qty: Math.max(0, p.qty - (b.qty || 1)) } }); else await db.product.update({ where: { id: p.id }, data: { status: "sold" } }); return { ok: true }; }, "GET /api/sourcing": async () => { const entries = await db.priceEntry.findMany({ orderBy: [{ item: "asc" }, { id: "desc" }], include: { supplier: true } }); return { suppliers: (await db.supplier.findMany({ orderBy: { name: "asc" } })).map(sSupplier), entries: entries.map((e) => ({ id: e.id, item: e.item, supplier_id: e.supplierId, price: e.price, unit: e.unit, shipping: e.shipping, note: e.note, created: fmtDate(e.created), supplier: e.supplier.name, supplier_kind: e.supplier.kind, distance: e.supplier.distance })), }; }, "POST /api/suppliers": async (b) => { const s = await db.supplier.create({ data: { name: b.name, kind: b.kind || "online", distance: b.distance || "", url: b.url || "", notes: b.notes || "", searchUrl: b.search_url || "" } }); return { id: s.id }; }, "POST /api/price-entries": async (b) => { await db.priceEntry.create({ data: { item: b.item, supplierId: b.supplier_id, price: Number(b.price) || 0, unit: b.unit || "", shipping: Number(b.shipping) || 0, note: b.note || "" } }); return { ok: true }; }, "GET /api/files": async () => { const files = await db.fileAsset.findMany({ orderBy: { id: "desc" } }); const idsOf = (entity) => [...new Set(files.filter((f) => f.entity === entity).map((f) => f.entityId))]; const products = await db.product.findMany({ where: { id: { in: idsOf("product") } } }); const courses = await db.course.findMany({ where: { id: { in: idsOf("course") } } }); const nameOf = Object.fromEntries(products.map((p) => [p.id, p.name])); const courseOf = Object.fromEntries(courses.map((c) => [c.id, c.title])); return files.map((f) => ({ ...sFile(f), product_name: f.entity === "product" ? nameOf[f.entityId] ?? null : null, course_name: f.entity === "course" ? courseOf[f.entityId] ?? null : null })); }, // ── Classes binder ───────────────────────────────────────────── "GET /api/courses": async () => (await db.course.findMany({ orderBy: [{ status: "asc" }, { title: "asc" }], include: { _count: { select: { pages: true } } } })).map(sCourse), "GET /api/course": async (_b, q) => { const id = Number(q.get("id")); const c = await db.course.findUnique({ where: { id }, include: { pages: { orderBy: { id: "desc" } } } }); if (!c) return null; return { ...sCourse(c), pages: c.pages.map(sPage), photos: (await db.photo.findMany({ where: { entity: "course", entityId: id } })).map(sPhoto), files: (await db.fileAsset.findMany({ where: { entity: "course", entityId: id }, orderBy: { id: "desc" } })).map(sFile) }; }, "POST /api/courses": async (b) => { const c = await db.course.create({ data: { title: b.title, subject: b.subject || "", source: b.source || "", url: b.url || "", status: b.status || "taking", notes: b.notes || "" } }); return { id: c.id }; }, "POST /api/course/update": async (b) => { const data = {}; for (const k of ["title", "subject", "source", "url", "status", "notes"]) if (typeof b[k] === "string") data[k] = b[k]; await db.course.update({ where: { id: b.id }, data }); return { ok: true }; }, "POST /api/course/delete": async (b) => { await db.course.delete({ where: { id: b.id } }); // pages cascade await db.photo.deleteMany({ where: { entity: "course", entityId: b.id } }); for (const f of await db.fileAsset.findMany({ where: { entity: "course", entityId: b.id } })) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } } await db.fileAsset.deleteMany({ where: { entity: "course", entityId: b.id } }); return { ok: true }; }, "POST /api/course/pages": async (b) => { const p = await db.coursePage.create({ data: { courseId: b.course_id, title: b.title || "", text: b.text || "" } }); return { id: p.id }; }, "POST /api/course/pages/update": async (b) => { await db.coursePage.update({ where: { id: b.id }, data: { ...(typeof b.title === "string" ? { title: b.title } : {}), ...(typeof b.text === "string" ? { text: b.text } : {}), updated: new Date() } }); return { ok: true }; }, "POST /api/course/pages/delete": async (b) => { await db.coursePage.delete({ where: { id: b.id } }); return { ok: true }; }, // ── Mind dump ────────────────────────────────────────────────── "GET /api/ideas": async () => { const list = await db.idea.findMany({ orderBy: { id: "desc" } }); const photos = await db.photo.findMany({ where: { entity: "idea" } }); return list.map((i) => ({ ...sIdea(i), photos: photos.filter((p) => p.entityId === i.id).map(sPhoto) })); }, "POST /api/ideas": async (b) => { const i = await db.idea.create({ data: { text: b.text, category: b.category || "", medium: b.medium || "", status: b.status || "spark" } }); return { id: i.id }; }, "POST /api/ideas/update": async (b) => { const data = {}; for (const k of ["text", "category", "medium", "status"]) if (typeof b[k] === "string") data[k] = b[k]; await db.idea.update({ where: { id: b.id }, data }); return { ok: true }; }, "POST /api/ideas/delete": async (b) => { await db.idea.delete({ where: { id: b.id } }); await db.photo.deleteMany({ where: { entity: "idea", entityId: b.id } }); return { ok: true }; }, "POST /api/files/delete": async (b) => { const f = await db.fileAsset.findUnique({ where: { id: b.id } }); if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } await db.fileAsset.delete({ where: { id: f.id } }); } return { ok: true }; }, // self-update: trigger the rebuild, and poll status + "what's the latest version" // (one OTM endpoint answers both). The build outlives the POST — expect 202, then poll. // Owner-gated like every danger-tier action (an operator session impersonates // the owner, so it passes) — matches the shared factory's mandatory guard. "POST /api/account/self-update": async (_b, _q, session) => { if (!(await isOwner(session))) return { _status: 401, error: "unauthorized" }; const c = otmCreds(); if (!c) return { _status: 503, error: "not_configured" }; return otmProxy(`${c.base}/api/instance/self-update`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ serviceId: c.serviceId, ts: c.ts, sig: c.sig }) }); }, "GET /api/account/self-update": async (_b, _q, session) => { if (!(await isOwner(session))) return { _status: 401, error: "unauthorized" }; const c = otmCreds(); if (!c) return { _status: 503, error: "not_configured" }; const q = new URLSearchParams({ serviceId: c.serviceId, ts: c.ts, sig: c.sig }); return otmProxy(`${c.base}/api/instance/self-update?${q}`); }, }; const isOwner = async (session) => session?.operator ? true : !!(await db.user.findFirst({ where: { email: session?.email || "", role: "OWNER" } })); const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" }; const PUBLIC_PATHS = new Set(["/api/health", "/api/login", "/login", "/login.html", "/api/auth/operator-magic", "/api/auth/otm-sso"]); createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const send = (code, body, type = "application/json", extraHeaders = {}) => { res.writeHead(code, { "content-type": type, ...extraHeaders }); res.end(type === "application/json" ? JSON.stringify(body) : body); }; try { const session = verify(cookieOf(req).bms_session); const isPublic = PUBLIC_PATHS.has(url.pathname); if (url.pathname === "/login" || url.pathname === "/login.html") return send(200, readFileSync(join(ROOT, "public", "login.html")), "text/html"); if (!session && !isPublic) { if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/photos/") || url.pathname.startsWith("/file/")) return send(401, { error: "not logged in" }); return send(302, "", "text/plain", { location: "/login" }); } // uploads (auth required — session checked above) if (req.method === "POST" && url.pathname === "/api/photos") { const chunks = []; for await (const c of req) chunks.push(c); const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase(); const filename = randomUUID() + ext; writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks)); await db.photo.create({ data: { entity: url.searchParams.get("entity") || "note", entityId: Number(url.searchParams.get("id") || 0), filename } }); return send(200, { ok: true, filename }); } if (req.method === "POST" && url.pathname === "/api/files") { const chunks = []; for await (const c of req) chunks.push(c); const buf = Buffer.concat(chunks); const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_"); const stored = randomUUID() + extname(orig).toLowerCase(); writeFileSync(join(FILES, stored), buf); await db.fileAsset.create({ data: { entity: url.searchParams.get("entity") || "", entityId: Number(url.searchParams.get("id") || 0), stored, name: orig, tag: url.searchParams.get("tag") || "", size: buf.length } }); return send(200, { ok: true }); } if (url.pathname.startsWith("/file/")) { const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, ""); const f = await db.fileAsset.findFirst({ where: { stored } }); if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" }); return send(200, readFileSync(join(FILES, stored)), MIME[extname(stored)] || "application/octet-stream", { "content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"` }); } if (url.pathname.startsWith("/photos/")) { const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, "")); if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream"); return send(404, { error: "not found" }); } const handler = routes[`${req.method} ${url.pathname}`]; if (handler) { let body = {}; if (req.method === "POST") { const chunks = []; let size = 0; for await (const c of req) { // JSON routes only — photo/file uploads have their own readers size += c.length; if (size > 1e6) { req.destroy(); return send(413, { error: "too large" }); } chunks.push(c); } body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; } const result = (await handler(body, url.searchParams, session)) ?? { ok: true }; const headers = {}; if (result._setSession) { // Max-Age follows the payload's own exp — a 1h operator session must not // leave a 30-day cookie behind. const maxAge = Math.max(0, Math.floor((result._setSession.exp - Date.now()) / 1000)); headers["set-cookie"] = `bms_session=${sign(result._setSession)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`; delete result._setSession; } if (result._clearSession) { headers["set-cookie"] = "bms_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0"; delete result._clearSession; } if (result._redirect) return send(302, "", "text/plain", { ...headers, location: result._redirect }); const code = result._status ?? 200; // OTM-proxy routes pass their upstream status through delete result._status; return send(code, result, "application/json", headers); } // static app const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, ""); const path = join(ROOT, "public", file); if (existsSync(path) && !path.includes("..")) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain"); send(404, { error: "not found" }); } catch (e) { console.error(e); send(500, { error: String(e.message || e) }); } }).listen(PORT, "0.0.0.0", () => { console.log(`🌙 bms-backbone ${APP_VERSION} listening on :${PORT}`); });