app: OTM integrations — operator-magic + account SSO + suggestion jots + self-update (v0.4.0)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmybqyQmZWfcqA1vMP4jbQ
This commit is contained in:
2026-08-09 22:31:34 -05:00
parent 3269032525
commit 9e21895232
10 changed files with 122 additions and 11 deletions

8
.gitattributes vendored Normal file
View File

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

View File

@@ -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 **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, `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 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 owner-seed auth). Deploy sequence: bump `app/lib/version.mjs` **and
OTM admin, Build template `bms-backbone-next` → update/recreate the instance. `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 Bonna's laptop data was migrated; `prototype/` is now legacy reference (the
laptop `.command` still works but the hosted notebook is the daily driver). 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 ## The repo
- Gitea: `https://git.poweredbyotm.com/bonna61/Bonna-Moon-Studio.git` - Gitea: `https://git.poweredbyotm.com/bonna61/Bonna-Moon-Studio.git`

View File

@@ -1 +1 @@
export const APP_VERSION = "0.3.0"; export const APP_VERSION = "0.4.0";

4
app/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.2.0", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.2.0", "version": "0.4.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.7.0", "@prisma/client": "^6.7.0",
"bcryptjs": "^3.0.2" "bcryptjs": "^3.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "bms-backbone", "name": "bms-backbone",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -15,6 +15,14 @@ model User {
created DateTime @default(now()) 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 { model Note {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
text String text String

View File

@@ -22,13 +22,14 @@ document.querySelectorAll("#nav button").forEach((b) =>
function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); } function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); }
// ── jot box (shared) ───────────────────────────────────────────── // ── jot box (shared) ─────────────────────────────────────────────
const JOT_HINT = "⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot";
function captureHTML(kinds) { function captureHTML(kinds) {
const chips = kinds.map(([k, label], i) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join(""); const chips = kinds.map(([k, label], i) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
return `<div class="capture"><div class="row1"> return `<div class="capture"><div class="row1">
<textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea> <textarea id="jot" rows="1" style="flex:1;resize:none" placeholder="Jot anything… a thought, a task, something to buy"></textarea>
<button class="go" id="jot-save">Save</button></div> <button class="go" id="jot-save">Save</button></div>
<div class="chips"><span class="hint">file it as…</span>${chips}</div> <div class="chips"><span class="hint">file it as…</span>${chips}</div>
<div class="chips"><span class="hint">⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot</span></div></div>`; <div class="chips"><span class="hint" id="jot-hint">${JOT_HINT}</span></div></div>`;
} }
function wireCapture(after) { function wireCapture(after) {
const jot = $("#jot"); const jot = $("#jot");
@@ -39,10 +40,12 @@ function wireCapture(after) {
const text = jot.value.trim(); const text = jot.value.trim();
if (!text) return; if (!text) return;
const kind = $(".capture .chip.on")?.dataset.kind || "note"; const kind = $(".capture .chip.on")?.dataset.kind || "note";
await api("/api/jot", { text, kind }); const r = await api("/api/jot", { text, kind });
await after(); await after();
const j = $("#jot"); const j = $("#jot");
if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting 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-save").addEventListener("click", save);
jot.addEventListener("keydown", (e) => { jot.addEventListener("keydown", (e) => {
@@ -57,6 +60,13 @@ function wireCapture(after) {
const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; }); const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; });
jot.addEventListener("input", autogrow); 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 // "/" from anywhere jumps to the jot box
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) { if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) {
@@ -90,7 +100,7 @@ async function today() {
? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div> ? `<div class="todo"><span>🔥 <b>Firing #${s.activeFiring.id}</b> in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}</span></div>
<div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>` <div class="mt"><button class="btn mini" id="goto-firing">go to firing</button></div>`
: `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`; : `<div class="todo"><span>Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}</span></div>`;
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"]]) +
`<div class="grid g2"> `<div class="grid g2">
<div class="card"><h3>To-do</h3>${todoRows}</div> <div class="card"><h3>To-do</h3>${todoRows}</div>
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div> <div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
@@ -604,7 +614,7 @@ async function sourcing() {
// ── Notes ──────────────────────────────────────────────────────── // ── Notes ────────────────────────────────────────────────────────
async function notes() { async function notes() {
const list = await api("/api/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"]]) +
`<div class="grid g2">${list.map((n) => ` `<div class="grid g2">${list.map((n) => `
<div class="card"><p style="font-size:14.5px">${esc(n.text)}</p> <div class="card"><p style="font-size:14.5px">${esc(n.text)}</p>
<div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span> <div class="mt"><span class="meta">${esc(n.created.slice(0, 16))}</span>
@@ -618,4 +628,61 @@ document.getElementById("signout")?.addEventListener("click", async () => {
location.href = "/login"; 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) ? ` <span class="chip" id="up-go">✨ update to v${esc(s.latest)}</span>` : "")
+ `<span id="up-said">${updating ? " · updating the studio… ✨" : ""}</span>`;
$("#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(); 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);
})();

View File

@@ -84,6 +84,7 @@
.matrow input:first-child { flex: 1; } .matrow input:first-child { flex: 1; }
.matrow input:last-of-type { width: 80px; } .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 { 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; }
</style> </style>
</head> </head>
<body> <body>
@@ -106,7 +107,7 @@
<div class="sub" id="greeting">welcome back</div> <div class="sub" id="greeting">welcome back</div>
</header> </header>
<main id="view"></main> <main id="view"></main>
<footer>🌙 Studio Notebook · bms.poweredbyotm.com</footer> <footer id="foot">🌙 Studio Notebook · bms.poweredbyotm.com</footer>
</div> </div>
</div> </div>
<script src="/app.js"></script> <script src="/app.js"></script>

View File

@@ -35,6 +35,14 @@
</form> </form>
</div> </div>
<script> <script>
// ?reason=… arrives when an OTM "Log in as admin" link didn't take
const REASONS = { not_enabled: "admin sign-in isn't set up for this studio yet",
invalid_token: "that sign-in link isn't valid", link_already_used: "that sign-in link was already used — ask for a fresh one",
no_admin: "there's no owner account here to sign in as", no_account: "no studio account matches your OTM account yet",
server_error: "something went wrong — try again" };
const reason = new URLSearchParams(location.search).get("reason");
if (typeof REASONS[reason] === "string") document.getElementById("err").textContent = REASONS[reason];
document.getElementById("f").addEventListener("submit", async (e) => { document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" }, const r = await fetch("/api/login", { method: "POST", headers: { "content-type": "application/json" },

Binary file not shown.