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