From e1798148591fbd149e2f5f38623d873dca2e095c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 19 Aug 2026 09:51:07 -0400 Subject: [PATCH] private structures --- db.js | 72 ++++++++++++++++++++++- index.js | 101 ++++++++++++++++++++++++++++----- migrations/005_add_privacy.sql | 15 +++++ plumbing.js | 38 +++++++++++-- views/workshop/members.eta | 28 +++++++++ views/workshop/settings.eta | 7 +++ 6 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 migrations/005_add_privacy.sql create mode 100644 views/workshop/members.eta diff --git a/db.js b/db.js index da94c1c..ad1b365 100644 --- a/db.js +++ b/db.js @@ -203,10 +203,74 @@ function createStructure(name, userId) { const stmt = db.prepare( "INSERT INTO structures (name, user_id) VALUES (?, ?)", ); - const info = stmt.run(name, userId); + const info = stmt.run(name, userId ?? null); return info.lastInsertRowid; // Returns the structure_id of the newly created structure } +// ---- access control (private structures) -------------------------------- +// +// A structure is public unless `private` is set. Access to a private structure +// is exactly its member list (structure_members) — a flat set of equals, no +// creator or owner. Anyone on the list can reach it, edit it, manage the list, +// or make it public again. This is the single source of truth the platform +// consults before serving a user route, mounting a db, or opening the workshop +// — never the structure's own code. + +function isMember(structureId, userId) { + return !!db + .prepare( + "SELECT 1 FROM structure_members WHERE structure_id = ? AND user_id = ?", + ) + .get(structureId, userId); +} + +function canAccessStructure(userId, structureId) { + const s = getStructure(structureId); + if (!s) return false; + if (!s.private) return true; + if (userId == null) return false; + return isMember(structureId, userId); +} + +// Members of a structure, with their usernames, for the settings UI. +function getMembers(structureId) { + return db + .prepare( + `SELECT sv.user_id, u.username, sv.created_at + FROM structure_members sv + JOIN users u ON u.id = sv.user_id + WHERE sv.structure_id = ? + ORDER BY sv.created_at ASC`, + ) + .all(structureId); +} + +function addMember(structureId, userId) { + db.prepare( + "INSERT OR IGNORE INTO structure_members (structure_id, user_id) VALUES (?, ?)", + ).run(structureId, userId); +} + +function removeMember(structureId, userId) { + db.prepare( + "DELETE FROM structure_members WHERE structure_id = ? AND user_id = ?", + ).run(structureId, userId); +} + +// Set a structure's privacy. Anyone may lock any structure down; the actor is +// added to the member list so they don't shut themselves out. (Access is purely +// the member set, so without this the person who flipped it private would lose +// the very structure they just privatized.) +function setStructurePrivacy(structureId, isPrivate, actorUserId) { + const s = getStructure(structureId); + if (!s) return; + db.prepare("UPDATE structures SET private = ? WHERE id = ?").run( + isPrivate ? 1 : 0, + structureId, + ); + if (isPrivate && actorUserId != null) addMember(structureId, actorUserId); +} + function createRoute(verb, path, structureId, handler) { path = encodeURI(path); @@ -769,6 +833,12 @@ module.exports = { getStructures, getStructure, createStructure, + canAccessStructure, + isMember, + getMembers, + addMember, + removeMember, + setStructurePrivacy, createRoute, getRoutes, getRoute, diff --git a/index.js b/index.js index 279c55b..0339181 100644 --- a/index.js +++ b/index.js @@ -72,6 +72,16 @@ app.use( app.use("/", wsRouter); app.use("/plumbing", require("./plumbing")); +// Every workshop route is scoped by :structure_id, so one param guard covers +// the whole editor: a private structure 404s for anyone who isn't on its member +// list, exactly like its user-facing routes do. +app.param("structure_id", (req, res, next, id) => { + if (!model.canAccessStructure(req.session.userId, id)) { + return res.status(404).send("Not found"); + } + next(); +}); + const vapidPublicKey = process.env.VAPID_PUBLIC_KEY; const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY; @@ -165,10 +175,21 @@ function runLibrary(sql, librarySource, console) { return libContext.module.exports; } -// The `require(name)` targets available to a user handler. -function makeLibs(structureId, console) { +// The `require(name)` targets available to a user handler. `memberUserId` is the +// user on whose behalf the handler runs. A structure can always mount its own +// dbs, but a *foreign* db aliased in from another structure is only mounted if +// that structure is reachable by this user — otherwise require('db')(alias) +// throws, so aliasing a private db into a public structure can't leak it. (WS +// handlers compile with no user; own dbs still mount, foreign private ones don't.) +function makeLibs(structureId, console, 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; + } const sql = model.getDbInstance(appDb.id); dbs[appDb.alias] = { library: runLibrary(sql, appDb.library, console), @@ -177,7 +198,12 @@ function makeLibs(structureId, console) { } return { eta: model.getTemplater(structureId), - db: (alias) => dbs[alias], + db: (alias) => { + if (forbidden.has(alias)) { + throw new Error(`db '${alias}' is private; you do not have access`); + } + return dbs[alias]; + }, push: push, files: { saveFile: (...args) => saveFile(structureId, ...args) }, }; @@ -197,10 +223,10 @@ function currentUserFor(req) { } } -function bootstrapContext(structureId, routeId, initContext) { +function bootstrapContext(structureId, routeId, initContext, memberUserId) { const structure = model.getStructure(structureId); const console = makeConsole(structureId, routeId); - const libs = makeLibs(structureId, console); + const libs = makeLibs(structureId, console, memberUserId); return vm.createContext({ ...initContext, @@ -227,6 +253,11 @@ function compileWebsocketHandler(route) { try { // Keep the existing WS handler context for compatibility. Restricting the // exposed capabilities is a separate runtime-sandboxing change. + // + // WS handlers compile once at startup, not per connection, so there is no + // requesting user here. With no user, makeLibs still mounts the structure's + // own dbs but blocks foreign private ones. Who may actually *open* the socket + // is enforced per-connection below. const context = bootstrapContext(route.structure_id, route.id, { app }); const handler = vm.runInContext(`${route.handler}\n\nhandler;`, context); model.updateRoute({ ...route, error: null }); @@ -284,6 +315,9 @@ wsRouter.ws("*", (ws, req) => { } const { route } = found; + if (!model.canAccessStructure(req.session && req.session.userId, route.structure_id)) { + return ws.close(1008, "WebSocket route not found"); + } req.params = found.params; let clients = wsConnections.get(route.id); if (!clients) { @@ -594,9 +628,10 @@ app.post("/workshop", (req, res) => { }); app.get("/workshop", (req, res) => { - return renderWorkshop(res, "workshop/index", { - structures: model.getStructures(), - }); + const structures = model + .getStructures() + .filter((s) => model.canAccessStructure(req.session.userId, s.id)); + return renderWorkshop(res, "workshop/index", { structures }); }); function renderWorkshop(res, template, context) { @@ -928,6 +963,7 @@ app.get("/workshop/:structure_id/files", (req, res) => { app.get("/workshop/:structure_id/settings", (req, res) => { return renderWorkshop(res, "workshop/settings", { + members: model.getMembers(req.params.structure_id), ...sidebarStuff(req.params.structure_id), }); }); @@ -944,6 +980,15 @@ app.put("/workshop/:structure_id/settings", (req, res) => { routePrefix = routePrefix.substring(0, routePrefix.length - 1); } + // An unchecked checkbox sends no field at all, so absence means "public". + const wantPrivate = req.body.private === "on" || req.body.private === "1"; + if (wantPrivate && !req.session.userId) { + return res + .status(403) + .send("log in before making a structure private, or you'll lock everyone out"); + } + model.setStructurePrivacy(structId, wantPrivate, req.session.userId); + model.updateStruct({ ...struct, route_prefix: routePrefix, @@ -955,6 +1000,29 @@ app.put("/workshop/:structure_id/settings", (req, res) => { res.send("success!"); }); +// Invite a member to a private structure by username. Renders the updated +// member list back into the settings page (hx-target). +app.post("/workshop/:structure_id/members", (req, res) => { + const structId = req.params.structure_id; + const user = model.getUser((req.body.username || "").trim()); + if (user) model.addMember(structId, user.id); + return renderWorkshop(res, "workshop/members", { + structure: model.getStructure(structId), + members: model.getMembers(structId), + notFound: user ? null : req.body.username, + }); +}); + +app.delete("/workshop/:structure_id/members/:user_id", (req, res) => { + const structId = req.params.structure_id; + model.removeMember(structId, req.params.user_id); + return renderWorkshop(res, "workshop/members", { + structure: model.getStructure(structId), + members: model.getMembers(structId), + notFound: null, + }); +}); + app.get("/workshop/:structure_id/new_template_modal", (req, res) => { return renderWorkshop(res, "workshop/new_template_modal", { structure: model.getStructure(req.params.structure_id), @@ -999,6 +1067,12 @@ app.all("*", async (req, res) => { req.params = routeMatch.params; const route = routeMatch.route; + // Private structures are invisible to uninvited users: 404, so a private + // route is indistinguishable from one that doesn't exist. + if (!model.canAccessStructure(req.session.userId, route.structure_id)) { + return res.status(404).json({ success: false, message: "Path not found" }); + } + // Minimal, handcrafted view of the logged-in Bliss user for user routes. // Deliberately NOT the raw session/user object — just {id, username} — // so structures can greet whoever is logged in without exposing internals. @@ -1009,11 +1083,12 @@ app.all("*", async (req, res) => { try { // todo: only add req res to contexst when running the handler() // which means moving to runscript instead of runincontext for that - let context = bootstrapContext(route.structure_id, route.id, { - req, - res, - eta, - }); + let context = bootstrapContext( + route.structure_id, + route.id, + { req, res, eta }, + req.session.userId, + ); res.render = (template, context = {}) => { context.route = makeRoute(structure); diff --git a/migrations/005_add_privacy.sql b/migrations/005_add_privacy.sql new file mode 100644 index 0000000..32ec663 --- /dev/null +++ b/migrations/005_add_privacy.sql @@ -0,0 +1,15 @@ +-- Private structures. A structure is public by default (private = 0); when +-- private = 1 only the users listed in structure_members may reach its routes, +-- mount its dbs, or open it in the workshop — a flat set of equals, no creator +-- or owner. Enforcement lives in the platform (index.js / plumbing.js), never in +-- user code — see canAccessStructure in db.js. +ALTER TABLE structures ADD COLUMN private INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE structure_members ( + structure_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(structure_id, user_id), + FOREIGN KEY(structure_id) REFERENCES structures(id), + FOREIGN KEY(user_id) REFERENCES users(id) +); diff --git a/plumbing.js b/plumbing.js index 7fb1e69..5ffec49 100644 --- a/plumbing.js +++ b/plumbing.js @@ -11,6 +11,16 @@ const model = require("./db"); const router = express.Router(); +// 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. +router.param("id", (req, res, next, id) => { + if (!model.canAccessStructure(req.session && req.session.userId, 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. function json(handler) { return (req, res) => { @@ -26,10 +36,17 @@ function json(handler) { }; } -// All structures. +// All structures the caller is allowed to see (private ones they aren't a +// member of are omitted). router.get( "/structures", - json(() => model.getStructures()), + json((req) => + model + .getStructures() + .filter((s) => + model.canAccessStructure(req.session && req.session.userId, s.id), + ), + ), ); // One structure with everything the sidebar shows, in one call. @@ -87,10 +104,23 @@ router.get( json((req) => model.getVersions("db", req.params.dbId)), ); -// One version, including its full snapshot (the versioned fields). +// One version, including its full snapshot (the versioned fields). Not scoped +// by :id, so it carries its own access check against the version's structure. router.get( "/versions/:versionId", - json((req) => model.getVersion(req.params.versionId)), + 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; + } + return version; + }), ); // Logs for a route. ?since= returns only newer rows (for polling). diff --git a/views/workshop/members.eta b/views/workshop/members.eta new file mode 100644 index 0000000..c6b51d2 --- /dev/null +++ b/views/workshop/members.eta @@ -0,0 +1,28 @@ +
+ + <% if (!it.members.length) { %> +
no members yet — only you can see this while private
+ <% } %> + <% it.members.forEach((v) => { %> +
+ <%~ v.username %> + +
+ <% }) %> + <% if (it.notFound) { %> +
no user named "<%~ it.notFound %>"
+ <% } %> +
+ + +
+
diff --git a/views/workshop/settings.eta b/views/workshop/settings.eta index 4725f22..0f2a6db 100644 --- a/views/workshop/settings.eta +++ b/views/workshop/settings.eta @@ -27,9 +27,16 @@
+
+ > + +
+
+ <%~ include('/workshop/members', it) %> +