private structures

This commit is contained in:
Your Name 2026-08-19 09:51:07 -04:00
parent 1f7dbf21e0
commit e179814859
6 changed files with 243 additions and 18 deletions

72
db.js
View file

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

101
index.js
View file

@ -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);

View file

@ -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)
);

View file

@ -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=<id> returns only newer rows (for polling).

View file

@ -0,0 +1,28 @@
<div id="members" class="flex flex-col gap-1">
<label>Members</label>
<% if (!it.members.length) { %>
<div class="text-xs italic">no members yet — only you can see this while private</div>
<% } %>
<% it.members.forEach((v) => { %>
<div class="flex flex-row items-center gap-2 text-sm">
<span class="flex-grow"><%~ v.username %></span>
<button
hx-delete="/workshop/<%= it.structure.id %>/members/<%= v.user_id %>"
hx-target="#members"
hx-swap="outerHTML"
>remove</button>
</div>
<% }) %>
<% if (it.notFound) { %>
<div class="text-xs text-red-600">no user named "<%~ it.notFound %>"</div>
<% } %>
<form
hx-post="/workshop/<%= it.structure.id %>/members"
hx-target="#members"
hx-swap="outerHTML"
class="flex flex-row gap-1"
>
<input name="username" placeholder="username" class="flex-grow">
<button type="submit">invite</button>
</form>
</div>

View file

@ -27,9 +27,16 @@
<div class="text-xs"></div>
<textarea name="head_injection" value="<%= it.structure.route_prefix || "" %>"><%= it.structure.head_injection || "" %></textarea>
</div>
<div class="field-row">
<input type="checkbox" id="private" name="private" <%= it.structure.private ? "checked" : "" %>>
<label for="private">Private (only invited members)</label>
</div>
<button id="save-btn" type="submit">Save</button>
<div id="result"></div>
</form>
<div class="p-2 max-w-48">
<%~ include('/workshop/members', it) %>
</div>
</div>
</div>
</div>