From 9e21895232ef6859ccb6f1eb31ae40fdfefbd96c Mon Sep 17 00:00:00 2001 From: tonym Date: Sun, 9 Aug 2026 22:31:34 -0500 Subject: [PATCH] =?UTF-8?q?app:=20OTM=20integrations=20=E2=80=94=20operato?= =?UTF-8?q?r-magic=20+=20account=20SSO=20+=20suggestion=20jots=20+=20self-?= =?UTF-8?q?update=20(v0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new doors, all wire-contract ports of the shared @otm/account-panel factories (this backbone is plain Node β€” the Next factories can't mount): - GET /api/auth/operator-magic β€” OTM 'Log in as admin'. HS256 verify against OPERATOR_SHARED_SECRET (alg allowlist, constant-time, action-claim rejected for flow separation), single-use jti via OperatorMagicConsumed (+ who/when audit), 1-HOUR session with cookie Max-Age derived from the payload, πŸ”‘ support-session banner in the app, every failure a 302 reason redirect. - GET /api/auth/otm-sso β€” the OTM /account tile. Stricter sso-ticket verify (exp mandatory, audience compared), email-then-role actor mapping, safeNext. - πŸ’‘ suggestion jot chip β€” files a Gitea issue (shared attribution footer, 8k cap, 10s timeout); on any failure the jot is kept as a note instead. Success also leaves a '#N β€” …' note so she has her own record. - Footer version chip + one-tap self-update (HMAC-signed OTM proxy with explicit field picking β€” foreign JSON can never reach the _setSession/_redirect control keys), owner-gated, 10-min sessionStorage cache on the check. Hardening that rode along: session key now derived from OPERATOR_SHARED_SECRET (managed boot REFUSES the old derivable DATABASE_URL fallback), 1MB JSON body cap, login.html prototype-lookup fix. NOTE: server.mjs previously contained a literal NUL byte that made git treat it as binary β€” this commit re-encodes it as an escape (behavior identical) and adds .gitattributes so source diffs can never go blind again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SmybqyQmZWfcqA1vMP4jbQ --- .gitattributes | 8 +++++ CLAUDE.md | 23 ++++++++++-- app/lib/version.mjs | 2 +- app/package-lock.json | 4 +-- app/package.json | 2 +- app/prisma/schema.prisma | 8 +++++ app/public/app.js | 75 ++++++++++++++++++++++++++++++++++++--- app/public/index.html | 3 +- app/public/login.html | 8 +++++ app/server.mjs | Bin 19963 -> 30990 bytes 10 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..68b739f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# A stray control character once made git treat server.mjs as BINARY, which +# hid an entire security-relevant diff from review. Never again. +*.mjs text +*.js text +*.html text +*.css text +*.prisma text +*.md text diff --git a/CLAUDE.md b/CLAUDE.md index 29d057e..dc86ff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,30 @@ is captured in [VISION.md](VISION.md). The `site` instance is the storefront lay **The backbone is LIVE (2026-08-06): https://bms.poweredbyotm.com** β€” template `bms-backbone-next` (otm-admin 0.111.0), container `bms-backbone-bms` on anvil, built from this repo's `app/` (Postgres + Prisma port of the prototype, OTM -owner-seed auth). Deploy sequence: bump `app/lib/version.mjs` + push here β†’ in -OTM admin, Build template `bms-backbone-next` β†’ update/recreate the instance. +owner-seed auth). Deploy sequence: bump `app/lib/version.mjs` **and +`app/package.json`** + push here β†’ the push webhook now AUTO-BUILDS the image +(`autoBuildOnly`, otm-admin 0.111.1) β†’ deploying it stays a manual click on +OTM `/instances` (or `redeploy_service` via the MCP). Bonna's laptop data was migrated; `prototype/` is now legacy reference (the laptop `.command` still works but the hosted notebook is the daily driver). +## v0.4.0 β€” OTM integrations (what needs which env) + +All three are provision-injected via the template's flags; a hand-provision +that misses one degrades silently, so here's the lookup: + +| Feature | Env it needs | Symptom when unset | +|---|---|---| +| OTM "Log in as admin" (`GET /api/auth/operator-magic`) + OTM account SSO (`GET /api/auth/otm-sso`) | `OPERATOR_SHARED_SECRET`; SSO also `OTM_SSO_SECRET` + `OTM_SSO_AUDIENCE` | `/login?reason=not_enabled` | +| Suggestion jots β†’ Gitea issues (the πŸ’‘ chip) | `GITEA_URL` + `GITEA_TOKEN` + `GITEA_REPO` | jot saved as a note tagged `suggestion` instead (`fallback: true`) β€” never lost | +| Self-update (footer ✨ chip) | `OTM_API_BASE` + `OTM_SERVICE_ID` + `OPERATOR_SHARED_SECRET` | footer shows no update chip; API answers 503 `not_configured` | + +Session-signing note: the cookie key is derived from `OPERATOR_SHARED_SECRET` +(domain-separated HMAC) unless an explicit `SESSION_SECRET` is set; a managed +instance (`OTM_MANAGED=true`) with neither refuses to boot. OTM sign-ins are +recorded in `OperatorMagicConsumed` (jti + who + when); operator sessions last +1h and show a πŸ”‘ support-session banner. + ## The repo - Gitea: `https://git.poweredbyotm.com/bonna61/Bonna-Moon-Studio.git` diff --git a/app/lib/version.mjs b/app/lib/version.mjs index 8d2413d..be79714 100644 --- a/app/lib/version.mjs +++ b/app/lib/version.mjs @@ -1 +1 @@ -export const APP_VERSION = "0.3.0"; +export const APP_VERSION = "0.4.0"; diff --git a/app/package-lock.json b/app/package-lock.json index db159bf..d6fb391 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "bms-backbone", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bms-backbone", - "version": "0.2.0", + "version": "0.4.0", "dependencies": { "@prisma/client": "^6.7.0", "bcryptjs": "^3.0.2" diff --git a/app/package.json b/app/package.json index 426e3b4..b14be17 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "bms-backbone", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 269eca3..3aad118 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -15,6 +15,14 @@ model User { created DateTime @default(now()) } +// spent OTM sign-in tokens (operator-magic + otm-sso) β€” jti is the single-use +// key; operatorEmail is the audit trail of who came in through OTM +model OperatorMagicConsumed { + jti String @id + operatorEmail String @default("") + consumedAt DateTime @default(now()) +} + model Note { id Int @id @default(autoincrement()) text String diff --git a/app/public/app.js b/app/public/app.js index d68dd9a..7a22feb 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -22,13 +22,14 @@ document.querySelectorAll("#nav button").forEach((b) => function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); } // ── jot box (shared) ───────────────────────────────────────────── +const JOT_HINT = "⌨️ Enter saves Β· Shift+Enter = subtask line Β· Tab switches type Β· press / from anywhere to jot"; function captureHTML(kinds) { const chips = kinds.map(([k, label], i) => `${label}`).join(""); return `
file it as…${chips}
-
⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot
`; +
${JOT_HINT}
`; } function wireCapture(after) { const jot = $("#jot"); @@ -39,10 +40,12 @@ function wireCapture(after) { const text = jot.value.trim(); if (!text) return; const kind = $(".capture .chip.on")?.dataset.kind || "note"; - await api("/api/jot", { text, kind }); + const r = await api("/api/jot", { text, kind }); await after(); const j = $("#jot"); if (j) { j.value = ""; j.focus(); } // stay in the box β€” rapid-fire jotting + if (kind === "suggestion") jotSaid(r.issue ? `πŸ’‘ sent to Tony β€” suggestion #${r.issue}, thank you!` + : "πŸ’‘ kept in your notes β€” it'll get passed along"); }; $("#jot-save").addEventListener("click", save); jot.addEventListener("keydown", (e) => { @@ -57,6 +60,13 @@ function wireCapture(after) { const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; }); jot.addEventListener("input", autogrow); } +// the keyboard-hint line doubles as the jot box's confirmation slot +function jotSaid(msg) { + const h = $("#jot-hint"); + if (!h) return; + h.textContent = msg; + setTimeout(() => { const x = $("#jot-hint"); if (x && x.textContent === msg) x.textContent = JOT_HINT; }, 7000); +} // "/" from anywhere jumps to the jot box document.addEventListener("keydown", (e) => { if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) { @@ -90,7 +100,7 @@ async function today() { ? `
πŸ”₯ Firing #${s.activeFiring.id} in progress β€” ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}
` : `
Kiln idle${s.lastUnload ? ` β€” last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}
`; - view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "βœ“ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "πŸ§ͺ glaze idea"]]) + + view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "βœ“ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "πŸ§ͺ glaze idea"], ["suggestion", "πŸ’‘ suggestion"]]) + `

To-do

${todoRows}

Shopping list 🧺

${shopRows}
@@ -604,7 +614,7 @@ async function sourcing() { // ── Notes ──────────────────────────────────────────────────────── async function notes() { const list = await api("/api/notes"); - view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "βœ“ to-do"], ["shopping", "🧺 shopping"]]) + + view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "βœ“ to-do"], ["shopping", "🧺 shopping"], ["suggestion", "πŸ’‘ suggestion"]]) + `
${list.map((n) => `

${esc(n.text)}

${esc(n.created.slice(0, 16))} @@ -618,4 +628,61 @@ document.getElementById("signout")?.addEventListener("click", async () => { location.href = "/login"; }); +// ── version + one-tap update (OTM self-update) ─────────────────── +const foot = $("#foot"); +const said = (m) => { const s = $("#up-said"); if (s) s.textContent = m ? " Β· " + m : ""; }; +const get = (path) => fetch(path).then((r) => r.json()).catch(() => ({})); +const newerThan = (a, b) => { // "0.4.1" is newer than "0.4.0" + const p = (v) => [0, 1, 2].map((i) => Number(String(v || "").split(".")[i]) || 0); + const [x, y] = [p(a), p(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; + return false; +}; +// the container restarts mid-update, so tolerate failures and watch for the version to flip +async function awaitUpdate(from) { + for (let i = 0; i < 130; i++) { + await new Promise((r) => setTimeout(r, 3000)); + const v = (await get("/api/health")).version; + if (v && v !== from) return location.reload(); + } + said("still building β€” check back in a few minutes"); +} +async function versionLine() { + if (!foot) return; + const running = (await get("/api/health")).version || ""; + if (!running) return; // unknown version β†’ no chip (never a false "update!") + // the update check costs OTM a Gitea round-trip β€” cache it for 10 minutes + let s; + try { s = JSON.parse(sessionStorage.getItem("bms-update") || "null"); } catch { s = null; } + if (!s || Date.now() - s._at > 600e3 || s.status?.state === "running") { + s = await get("/api/account/self-update"); // 503 when self-update isn't wired β€” just no chip + s._at = Date.now(); + try { sessionStorage.setItem("bms-update", JSON.stringify(s)); } catch { /* private mode */ } + } + const updating = s.status?.state === "running"; + foot.innerHTML = `πŸŒ™ Studio Notebook${running ? " v" + esc(running) : ""} Β· bms.poweredbyotm.com` + + (!updating && newerThan(s.latest, running) ? ` ✨ update to v${esc(s.latest)}` : "") + + `${updating ? " Β· updating the studio… ✨" : ""}`; + $("#up-go")?.addEventListener("click", async () => { + $("#up-go").remove(); + said("starting the update β€” this takes a few minutes ✨"); + const r = await fetch("/api/account/self-update", { method: "POST" }).then((x) => x.json()).catch(() => ({})); + if (!r.ok) return said(r.message || r.error || "couldn't start the update"); + awaitUpdate(running); + }); + if (updating) awaitUpdate(running); +} + render(); +versionLine(); + +// a session opened through OTM "Log in as admin" is visibly marked β€” the owner +// (and the operator) should always be able to tell it apart from a real login +(async () => { + const me = await get("/api/me"); + if (!me.operator) return; + const b = document.createElement("div"); + b.textContent = `πŸ”‘ support session β€” ${me.operator} is signed in as the studio owner`; + b.style.cssText = "background:#f3e7f5;color:#6e4f78;text-align:center;padding:6px 10px;font-size:13px;"; + document.body.prepend(b); +})(); diff --git a/app/public/index.html b/app/public/index.html index 67b2991..6e73566 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -84,6 +84,7 @@ .matrow input:first-child { flex: 1; } .matrow input:last-of-type { width: 80px; } footer { text-align: center; color: #b1a0ae; font-size: 13px; margin-top: 32px; font-style: italic; font-family: Georgia, serif; } + footer .chip { font-family: "Avenir Next", "Nunito", sans-serif; font-style: normal; margin-left: 8px; } @@ -106,7 +107,7 @@
welcome back
-
πŸŒ™ Studio Notebook Β· bms.poweredbyotm.com
+
πŸŒ™ Studio Notebook Β· bms.poweredbyotm.com
diff --git a/app/public/login.html b/app/public/login.html index 02b05c6..b64f606 100644 --- a/app/public/login.html +++ b/app/public/login.html @@ -35,6 +35,14 @@