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

View File

@@ -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) => `<span class="chip${i === 0 ? " on" : ""}" data-kind="${k}">${label}</span>`).join("");
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>
<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">⌨️ 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) {
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() {
? `<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="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="card"><h3>To-do</h3>${todoRows}</div>
<div class="card"><h3>Shopping list 🧺</h3>${shopRows}</div>
@@ -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"]]) +
`<div class="grid g2">${list.map((n) => `
<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>
@@ -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) ? ` <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();
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);
})();