From f6bde50ce3b83148b45fc5fb0257479aa71506e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 22 Aug 2026 11:29:04 -0400 Subject: [PATCH] fix editor --- bliss-cli/live-editor/install.js | 6 +- bliss-cli/live-editor/route-editor.js | 19 ++- bliss-cli/live-editor/template-editor.js | 18 ++- plumbing.js | 141 +++++++++++++++++------ proof_plumbing_owner_checks.js | 83 +++++++++++++ proof_private_db.js | 88 ++++++++++++++ 6 files changed, 305 insertions(+), 50 deletions(-) create mode 100644 proof_plumbing_owner_checks.js create mode 100644 proof_private_db.js diff --git a/bliss-cli/live-editor/install.js b/bliss-cli/live-editor/install.js index 9ca0487..8804c56 100644 --- a/bliss-cli/live-editor/install.js +++ b/bliss-cli/live-editor/install.js @@ -32,9 +32,9 @@ if (!structure) { const structureId = String(structure.id); bliss("update-settings", structureId, "--route-prefix", "/_bliss"); let state = json("structure", structureId); -if (!state.dbs.some((db) => String(db.id) === "0" && db.alias === "bliss")) { - bliss("attach-db", structureId, "0", "bliss"); -} +// No db is attached: the editor reads structure sources through the /plumbing +// API (owner-checked), not by mounting the prime db — which is no longer +// attachable to other structures anyway. const templates = [ ["inspector/slideout", "slideout.eta"], diff --git a/bliss-cli/live-editor/route-editor.js b/bliss-cli/live-editor/route-editor.js index 85e2cb9..8026c52 100644 --- a/bliss-cli/live-editor/route-editor.js +++ b/bliss-cli/live-editor/route-editor.js @@ -1,7 +1,14 @@ -function handler(req, res) { - const sql = require("db")("bliss").sql; - const kind = "route"; - const artifact = sql.prepare("SELECT * FROM routes WHERE id = ?").get(req.params.id); - if (!artifact) return res.status(404).send("Route not found"); - res.render("inspector/artifact_editor", { kind, artifact }); +// Read a route's source through the plumbing API rather than mounting the prime +// db directly. The prime db is no longer attachable to other structures, and +// going through /plumbing means this read is owner-checked: forwarding the +// caller's session cookie, plumbing 404s any route in a structure they can't +// see, so the live editor can only open what the user is already allowed to edit. +async function handler(req, res) { + const base = `${req.protocol}://${req.get("host")}`; + const response = await fetch(`${base}/plumbing/routes/${req.params.id}`, { + headers: { cookie: req.headers.cookie || "" }, + }); + if (!response.ok) return res.status(response.status).send("Route not found"); + const artifact = await response.json(); + res.render("inspector/artifact_editor", { kind: "route", artifact }); } diff --git a/bliss-cli/live-editor/template-editor.js b/bliss-cli/live-editor/template-editor.js index 00843a1..e366ee1 100644 --- a/bliss-cli/live-editor/template-editor.js +++ b/bliss-cli/live-editor/template-editor.js @@ -1,7 +1,13 @@ -function handler(req, res) { - const sql = require("db")("bliss").sql; - const kind = "template"; - const artifact = sql.prepare("SELECT * FROM templates WHERE id = ?").get(req.params.id); - if (!artifact) return res.status(404).send("Template not found"); - res.render("inspector/artifact_editor", { kind, artifact }); +// Read a template's source through the plumbing API rather than mounting the +// prime db directly (see route-editor.js for the why). Forwarding the caller's +// session cookie keeps the read owner-checked: plumbing 404s any template in a +// structure the user can't see. +async function handler(req, res) { + const base = `${req.protocol}://${req.get("host")}`; + const response = await fetch(`${base}/plumbing/templates/${req.params.id}`, { + headers: { cookie: req.headers.cookie || "" }, + }); + if (!response.ok) return res.status(response.status).send("Template not found"); + const artifact = await response.json(); + res.render("inspector/artifact_editor", { kind: "template", artifact }); } diff --git a/plumbing.js b/plumbing.js index 5ffec49..bc59126 100644 --- a/plumbing.js +++ b/plumbing.js @@ -1,27 +1,45 @@ // plumbing.js — read-only JSON utility API. // // This is plumbing, not part of the product surface. The workshop UI renders -// HTML; anything that wants structured data (the bliss CLI, scripts, tooling) -// reads it here instead of scraping fragments. Read-only by design: all writes -// still go through the real /workshop/* endpoints a human uses, so this stays a -// thin mirror of model.* with no business logic of its own. +// HTML; anything that wants structured data (the bliss CLI, scripts, tooling, +// the live editor) reads it here instead of scraping fragments. Read-only by +// design: all writes still go through the real /workshop/* endpoints a human +// uses, so this stays a thin mirror of model.* with no business logic of its +// own. +// +// Access control invariant: every row handed out is gated against the structure +// it *actually belongs to*, using the same rules as the runtime (see +// canAccessStructure / makeLibs in index.js). Structure-scoped URLs additionally +// require the artifact to belong to the :id in the path, so you can't launder a +// routeId/templateId/dbId from a structure you can't see through one you can. const express = require("express"); const model = require("./db"); const router = express.Router(); +function currentUserId(req) { + return req.session && req.session.userId; +} + +function canAccess(req, structureId) { + return model.canAccessStructure(currentUserId(req), structureId); +} + // Private structures are invisible through the plumbing too. Every structure- // scoped route carries :id, so one param guard covers them all; a caller who -// isn't on the member list gets a 404, same as the workshop. +// isn't allowed to see it gets a 404, same as the workshop. router.param("id", (req, res, next, id) => { - if (!model.canAccessStructure(req.session && req.session.userId, id)) { + if (!canAccess(req, id)) { return res.status(404).json({ error: "not found" }); } next(); }); // Wrap a handler so thrown errors come back as JSON instead of an HTML stack. +// A null/undefined body is treated as "not found" (404) — helpers below return +// null whenever a lookup misses OR the caller isn't allowed to see it, so the +// two are deliberately indistinguishable from the outside. function json(handler) { return (req, res) => { try { @@ -36,16 +54,59 @@ function json(handler) { }; } +// ---- ownership-checked artifact lookups ---------------------------------- +// Each returns the row only if it exists AND the caller may see the structure +// it belongs to; null otherwise (=> 404). When `structureId` is supplied (a +// structure-scoped URL) the artifact must also belong to that structure. + +function routeFor(req, routeId, structureId) { + const route = model.getRoute(routeId); + if (!route) return null; + if (structureId !== undefined && String(route.structure_id) !== String(structureId)) { + return null; + } + if (!canAccess(req, route.structure_id)) return null; + return route; +} + +function templateFor(req, templateId, structureId) { + const template = model.getTemplate(templateId); + if (!template) return null; + if (structureId !== undefined && String(template.structure_id) !== String(structureId)) { + return null; + } + if (!canAccess(req, template.structure_id)) return null; + return template; +} + +// A db is visible if it's the structure's own db, or a foreign db aliased in +// from a structure the caller can also reach — mirrors makeLibs exactly, so the +// plumbing never exposes a private db's library that the runtime would refuse to +// mount. +function dbVisible(req, appDb, structureId) { + const ownDb = String(appDb.db_struct_id) === String(structureId); + return ownDb || canAccess(req, appDb.db_struct_id); +} + +function dbFor(req, structureId, dbId) { + const appDb = model.getDbForStructure(structureId, dbId); + if (!appDb) return null; + if (!dbVisible(req, appDb, structureId)) return null; + return appDb; +} + +function visibleDbs(req, structureId) { + return model + .getDbsForStructure(structureId) + .filter((appDb) => dbVisible(req, appDb, structureId)); +} + // All structures the caller is allowed to see (private ones they aren't a // member of are omitted). router.get( "/structures", json((req) => - model - .getStructures() - .filter((s) => - model.canAccessStructure(req.session && req.session.userId, s.id), - ), + model.getStructures().filter((s) => canAccess(req, s.id)), ), ); @@ -59,49 +120,64 @@ router.get( structure, routes: model.getRoutes(req.params.id), templates: model.getTemplates(req.params.id), - dbs: model.getDbsForStructure(req.params.id), + dbs: visibleDbs(req, req.params.id), files: model.getFilesForStruct(req.params.id), }; }), ); -// One route, including its handler source. +// One route, including its handler source. Both a structure-scoped form (for +// the CLI/sidebar) and an id-only form (for the live editor, which knows the +// artifact id but not always the structure) — both owner-checked. router.get( "/structures/:id/routes/:routeId", - json((req) => model.getRoute(req.params.routeId)), + json((req) => routeFor(req, req.params.routeId, req.params.id)), +); +router.get( + "/routes/:routeId", + json((req) => routeFor(req, req.params.routeId)), ); // One template, including content + test_object. router.get( "/structures/:id/templates/:templateId", - json((req) => model.getTemplate(req.params.templateId)), + json((req) => templateFor(req, req.params.templateId, req.params.id)), +); +router.get( + "/templates/:templateId", + json((req) => templateFor(req, req.params.templateId)), ); // One db (scoped to the structure), including its library source. router.get( "/structures/:id/dbs/:dbId", - json((req) => model.getDbForStructure(req.params.id, req.params.dbId)), -); - -// Files attached to a structure. -router.get( - "/structures/:id/files", - json((req) => model.getFilesForStruct(req.params.id)), + json((req) => dbFor(req, req.params.id, req.params.dbId)), ); // Version history (newest first) for a route handler, template, or db library. -// Each row is a summary; fetch /versions/:versionId for the full snapshot. +// Each row is a summary; fetch /versions/:versionId for the full snapshot. The +// artifact is owner-checked first, so you can only list versions of something +// you can already read. router.get( "/structures/:id/routes/:routeId/versions", - json((req) => model.getVersions("route", req.params.routeId)), + json((req) => + routeFor(req, req.params.routeId, req.params.id) && + model.getVersions("route", req.params.routeId), + ), ); router.get( "/structures/:id/templates/:templateId/versions", - json((req) => model.getVersions("template", req.params.templateId)), + json((req) => + templateFor(req, req.params.templateId, req.params.id) && + model.getVersions("template", req.params.templateId), + ), ); router.get( "/structures/:id/dbs/:dbId/versions", - json((req) => model.getVersions("db", req.params.dbId)), + json((req) => + dbFor(req, req.params.id, req.params.dbId) && + model.getVersions("db", req.params.dbId), + ), ); // One version, including its full snapshot (the versioned fields). Not scoped @@ -111,22 +187,17 @@ router.get( json((req) => { const version = model.getVersion(req.params.versionId); if (!version) return null; - if ( - !model.canAccessStructure( - req.session && req.session.userId, - version.structure_id, - ) - ) { - return null; - } + if (!canAccess(req, version.structure_id)) return null; return version; }), ); -// Logs for a route. ?since= returns only newer rows (for polling). +// Logs for a route. ?since= returns only newer rows (for polling). The route +// is owner-checked first so logs never leak from a structure you can't see. router.get( "/structures/:id/routes/:routeId/logs", json((req) => { + if (!routeFor(req, req.params.routeId, req.params.id)) return null; const { since } = req.query; const logs = since !== undefined diff --git a/proof_plumbing_owner_checks.js b/proof_plumbing_owner_checks.js new file mode 100644 index 0000000..fc3bd5e --- /dev/null +++ b/proof_plumbing_owner_checks.js @@ -0,0 +1,83 @@ +// Proof: the /plumbing read API only ever hands out an artifact to a caller who +// may see the structure it BELONGS TO — including via the id-only endpoints the +// live editor uses, and even when a private db is aliased into a public one. +// +// It mounts the REAL plumbing.js router on a throwaway express app (temp db), +// with a tiny middleware that fakes a logged-in session from an ?as= query, and +// makes real HTTP requests. So the assertions exercise the shipped access logic. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const express = require("express"); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bliss-plumbing-")); +fs.mkdirSync(path.join(tmp, "dbs")); +process.chdir(tmp); + +const PROJECT = "/home/happy/code/bliss"; +const model = require(path.join(PROJECT, "db.js")); +model.applyMigrations(); + +// ---- scenario ------------------------------------------------------------ +const alice = model.createUser("alice", "x"); // member of the private structure +const bob = model.createUser("bob", "x"); // outsider + +// PRIVATE structure owned by alice: its own route, template, and db. +const priv = model.createStructure("private-vault", alice); +const privRoute = model.createRoute("GET", "/secret", priv, "handler(){}"); +const privTemplate = model.createTemplate(priv, "secret_tpl", "top secret", ""); +const secretsDbId = model.createDb(priv, "secrets"); +model.setStructurePrivacy(priv, true, alice); + +// PUBLIC structure owned by bob that ALIASES alice's private db under "secrets". +const pub = model.createStructure("public-front", bob); +model.attachDb(pub, secretsDbId, "secrets"); + +// ---- app: real plumbing router, faked session from ?as= ------------------ +const app = express(); +app.use((req, _res, next) => { + const as = req.query.as; + req.session = { userId: as === "alice" ? alice : as === "bob" ? bob : null }; + next(); +}); +app.use("/plumbing", require(path.join(PROJECT, "plumbing.js"))); +const server = app.listen(0); +const port = server.address().port; + +async function get(url) { + const res = await fetch(`http://127.0.0.1:${port}${url}`); + return res.status; +} + +function expect(label, actual, wanted) { + const ok = actual === wanted; + console.log(` ${ok ? "PASS" : "FAIL"} | ${label} (got ${actual}, want ${wanted})`); + return ok; +} + +(async () => { + const results = []; + + console.log("\nid-only endpoints (what the live editor calls):"); + results.push(expect("alice reads her private route ", await get(`/plumbing/routes/${privRoute}?as=alice`), 200)); + results.push(expect("bob reads alice's private route ", await get(`/plumbing/routes/${privRoute}?as=bob`), 404)); + results.push(expect("anon reads alice's private route ", await get(`/plumbing/routes/${privRoute}`), 404)); + results.push(expect("alice reads her private template ", await get(`/plumbing/templates/${privTemplate}?as=alice`), 200)); + results.push(expect("bob reads alice's private template", await get(`/plumbing/templates/${privTemplate}?as=bob`), 404)); + + console.log("\nstructure-scoped endpoints reject id-laundering:"); + // bob CAN see his public structure, but the route id belongs to the private one. + results.push(expect("bob: pub structure + private routeId", await get(`/plumbing/structures/${pub}/routes/${privRoute}?as=bob`), 404)); + + console.log("\naliased private db is not leaked through a public structure:"); + results.push(expect("bob reads aliased private db ", await get(`/plumbing/structures/${pub}/dbs/${secretsDbId}?as=bob`), 404)); + results.push(expect("alice reads that db via her structure", await get(`/plumbing/structures/${priv}/dbs/${secretsDbId}?as=alice`), 200)); + + const pass = results.every(Boolean); + console.log(`\n${pass ? "PROVEN" : "FAILED"}: plumbing gates every artifact against its own structure's access rules.`); + + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + process.exit(pass ? 0 : 1); +})(); diff --git a/proof_private_db.js b/proof_private_db.js new file mode 100644 index 0000000..5049da0 --- /dev/null +++ b/proof_private_db.js @@ -0,0 +1,88 @@ +// Proof: a private structure's DB cannot be mounted by a handler running for a +// user who isn't a member — even when that DB is aliased into a PUBLIC structure. +// +// It drives the REAL model code from db.js (canAccessStructure, getDbsForStructure, +// attachDb, ...) against a throwaway database in a temp dir, and reproduces the +// gate from index.js:192-211 (makeLibs) verbatim so the throw is the app's, not ours. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// db.js opens "./dbs/0.sqlite" and writes user dbs under "./dbs" relative to CWD. +// Point CWD at a fresh temp dir so we never touch the real data. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bliss-proof-")); +fs.mkdirSync(path.join(tmp, "dbs")); +process.chdir(tmp); + +const PROJECT = "/home/happy/code/bliss"; +const model = require(path.join(PROJECT, "db.js")); +model.applyMigrations(); + +// ---- the exact gate from index.js makeLibs (lines 192-211), copied verbatim ---- +// Given the structure being served and the requesting user, return a db(alias) +// resolver identical to what a route handler receives via require('db'). +function makeDbResolver(structureId, memberUserId) { + const dbs = {}; + const forbidden = new Set(); + for (let appDb of model.getDbsForStructure(structureId)) { + const ownDb = String(appDb.db_struct_id) === String(structureId); + if (!ownDb && !model.canAccessStructure(memberUserId, appDb.db_struct_id)) { + forbidden.add(appDb.alias); + continue; + } + model.getDbInstance(appDb.id); // would-mount + } + return (alias) => { + if (forbidden.has(alias)) { + throw new Error(`db '${alias}' is private; you do not have access`); + } + return dbs[alias]; + }; +} + +function attempt(label, structureId, userId, alias) { + try { + makeDbResolver(structureId, userId)(alias); + console.log(` MOUNTED | ${label}`); + return "mounted"; + } catch (e) { + console.log(` BLOCKED | ${label} -> ${e.message}`); + return "blocked"; + } +} + +// ---- scenario ---- +const alice = model.createUser("alice", "x"); // member of the private structure +const bob = model.createUser("bob", "x"); // outsider + +// PRIVATE structure owned by alice, with its own DB "secrets". +const priv = model.createStructure("private-vault", alice); +const secretsDbId = model.createDb(priv, "secrets"); +model.setStructurePrivacy(priv, true, alice); // flips private=1, keeps alice a member + +// PUBLIC structure that ALIASES the private DB in under the same name. +const pub = model.createStructure("public-front", bob); +model.attachDb(pub, secretsDbId, "secrets"); + +console.log(`\nprivate structure #${priv} owns db #${secretsDbId} ("secrets"), private=${model.getStructure(priv).private}`); +console.log(`public structure #${pub} aliases that same db in as "secrets", private=${model.getStructure(pub).private}\n`); + +console.log("Serving the PUBLIC structure, handler calls require('db')('secrets'):"); +const r1 = attempt("as bob (outsider) ", pub, bob, "secrets"); +const r2 = attempt("as anonymous / WS (userId=null)", pub, null, "secrets"); +const r3 = attempt("as alice (member of private)", pub, alice, "secrets"); + +console.log("\nSanity — serving the PRIVATE structure itself (its OWN db, ownDb=true):"); +const r4 = attempt("as alice (member) ", priv, alice, "secrets"); + +// ---- assertions ---- +const pass = + r1 === "blocked" && r2 === "blocked" && r3 === "mounted" && r4 === "mounted"; +console.log( + `\n${pass ? "PROVEN" : "FAILED"}: aliasing a private db into a public structure does NOT leak it — ` + + `only users who can access the owning structure can mount it.`, +); + +fs.rmSync(tmp, { recursive: true, force: true }); +process.exit(pass ? 0 : 1);