Files
Bonna-Moon-Studio/app/server.mjs
Bonna ca642046c7 app: Classes binder + Mind Dump — v0.5.0
Two new tabs in the Studio Notebook:

- 📚 Classes: a binder for courses, workshops, books, and video series.
  Each class has a subject, source, link, status (taking / want / done),
  a what-it's-about note, photos, handout files, and dated pages of
  class notes that can be edited in place.
- 💭 Mind Dump: idea capture filed by category (things to make — with a
  medium: clay / wood / silver / metal / mixed — marketing, packaging,
  display & booth, shop & site, other) with spark / trying / did it /
  parked status, sketch photos, one-tap 'make it a to-do', and filters.
  The Today jot box gains a 💭 idea chip; those land as 'unsorted' to
  file later.

Schema: Course, CoursePage, Idea (additive; prisma db push at boot).
Files tab now labels attachments that belong to a class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:10:55 -05:00

625 lines
35 KiB
JavaScript

// 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}`);
});