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:
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal 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
|
||||||
23
CLAUDE.md
23
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
|
**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`
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.3.0";
|
export const APP_VERSION = "0.4.0";
|
||||||
|
|||||||
4
app/package-lock.json
generated
4
app/package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
BIN
app/server.mjs
BIN
app/server.mjs
Binary file not shown.
Reference in New Issue
Block a user