make em private

This commit is contained in:
Your Name 2026-08-21 09:37:19 -04:00
parent 1394ce1e0c
commit 02cdca748d
4 changed files with 123 additions and 37 deletions

105
db.js
View file

@ -126,7 +126,10 @@ function inspectableContext(data) {
function annotateTemplateHtml(html, template, data) {
const context = inspectableContext(data);
const contextId = randomUUID();
templateContextCache.set(contextId, { templateId: String(template.id), context });
templateContextCache.set(contextId, {
templateId: String(template.id),
context,
});
function annotate($, element) {
const current = $(element).attr("data-bliss-templates");
let templates = [];
@ -147,7 +150,10 @@ function annotateTemplateHtml(html, template, data) {
} catch (_) {}
}
contextIds[String(template.id)] = contextId;
$(element).attr("data-bliss-template-context-ids", JSON.stringify(contextIds));
$(element).attr(
"data-bliss-template-context-ids",
JSON.stringify(contextIds),
);
if (!$(element).attr("data-bliss-template-id")) {
$(element).attr({
"data-bliss-template-id": String(template.id),
@ -207,14 +213,20 @@ function createStructure(name, userId) {
return info.lastInsertRowid; // Returns the structure_id of the newly created structure
}
// ---- access control (private structures) --------------------------------
// ---- access control -------------------------------------------------------
//
// 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.
// Two independent flags, three tiers of reach:
//
// public = 1 -> anyone, no login (opt-in, with a warning)
// public = 0, private = 0 -> any logged-in user (the default)
// public = 0, private = 1 -> invited members only
//
// So login is required by default; making a structure public is the explicit
// way back onto the open web. `private` layers a stricter members-only gate on
// top: access is then exactly its member list (structure_members) — a flat set
// of equals, no creator or owner. 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
@ -227,9 +239,10 @@ function isMember(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);
if (s.public) return true; // open web, no login
if (userId == null) return false; // everything else needs a login
if (!s.private) return true; // any logged-in user
return isMember(structureId, userId); // members-only
}
// Members of a structure, with their usernames, for the settings UI.
@ -271,16 +284,28 @@ function setStructurePrivacy(structureId, isPrivate, actorUserId) {
if (isPrivate && actorUserId != null) addMember(structureId, actorUserId);
}
// Put a structure on the open web (public = 1) or take it back off (login
// required). Independent of the members-only `private` flag; public wins in
// canAccessStructure, so a public structure is reachable regardless of private.
function setStructurePublic(structureId, isPublic) {
const s = getStructure(structureId);
if (!s) return;
db.prepare("UPDATE structures SET public = ? WHERE id = ?").run(
isPublic ? 1 : 0,
structureId,
);
}
function createRoute(verb, path, structureId, handler) {
path = encodeURI(path);
const stmt = db.prepare(
"INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)"
"INSERT INTO routes (verb, path, structure_id, handler) VALUES (?, ?, ?, ?)",
);
const info = stmt.run(verb, path, structureId, handler);
const routeId = info.lastInsertRowid; // Get the newly created route ID
if (verb !== 'GET') {
if (verb !== "GET") {
createScaffoldPage(routeId);
}
@ -306,13 +331,17 @@ const ROUTE_SELECT = `
function getRoutes(structureId) {
return db
.prepare(`${ROUTE_SELECT} WHERE r.structure_id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC`)
.prepare(
`${ROUTE_SELECT} WHERE r.structure_id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC`,
)
.all(structureId);
}
function getRoute(routeId) {
return db
.prepare(`${ROUTE_SELECT} WHERE r.id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC LIMIT 1`)
.prepare(
`${ROUTE_SELECT} WHERE r.id = ? ORDER BY sp.created_at DESC, sp2.created_at DESC LIMIT 1`,
)
.get(routeId);
}
@ -321,7 +350,9 @@ function update(table, fields, obj) {
const placeholders = fields.map((field) => `${field} = ?`).join(", ");
const values = fields.map((field) => obj[field]);
values.push(obj.id);
return db.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`).run(...values);
return db
.prepare(`UPDATE ${table} SET ${placeholders} WHERE id = ?`)
.run(...values);
}
// ---- version history (append-only) --------------------------------------
@ -337,7 +368,11 @@ function snapshotFor(entityType, obj) {
case "route":
return { verb: obj.verb, path: obj.path, handler: obj.handler };
case "template":
return { name: obj.name, content: obj.content, test_object: obj.test_object };
return {
name: obj.name,
content: obj.content,
test_object: obj.test_object,
};
case "db":
return { name: obj.name, library: obj.library };
default:
@ -377,7 +412,11 @@ function getVersion(versionId) {
}
function updateRoute(route) {
update("routes", ["verb", "path", "structure_id", "handler", "updated_at", "error"], route);
update(
"routes",
["verb", "path", "structure_id", "handler", "updated_at", "error"],
route,
);
recordVersion("route", route.id, route.structure_id, route);
}
@ -689,18 +728,25 @@ function getMostRecentLogIdByRoute(routeId) {
}
function buildScaffoldUrl(endpoint, urlParams, queryString) {
let populatedUrl = endpoint.replace(/:([^/]+)/g, () => urlParams.shift() || '');
let populatedUrl = endpoint.replace(
/:([^/]+)/g,
() => urlParams.shift() || "",
);
return queryString ? `${populatedUrl}?${queryString}` : populatedUrl;
}
function createScaffoldPage(routeId, content=null) {
function createScaffoldPage(routeId, content = null) {
if (!content) {
const route = getRoute(routeId)
const endpoint = buildScaffoldUrl(route.path, route.url_params, route.query_params);
const route = getRoute(routeId);
const endpoint = buildScaffoldUrl(
route.path,
route.url_params,
route.query_params,
);
content = getDefaultScaffoldContentByVerb(endpoint, route.verb);
}
const stmt = db.prepare(
"INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)"
"INSERT INTO scaffold_pages (route_id, content) VALUES (?, ?)",
);
const info = stmt.run(routeId, content);
return info.lastInsertRowid; // Returns the scaffold_page id of the newly created scaffold page
@ -708,7 +754,7 @@ function createScaffoldPage(routeId, content=null) {
function getLatestScaffoldPage(routeId) {
const stmt = db.prepare(
"SELECT * FROM scaffold_pages WHERE route_id = ? ORDER BY created_at DESC LIMIT 1"
"SELECT * FROM scaffold_pages WHERE route_id = ? ORDER BY created_at DESC LIMIT 1",
);
return stmt.get(routeId);
}
@ -819,11 +865,9 @@ function generateWSPage(endpoint) {
function getDefaultScaffoldContentByVerb(url, verb) {
if (verb == "POST") {
return generatePostForm(url);
}
else if (verb == "PUT" || verb == "DELETE") {
} else if (verb == "PUT" || verb == "DELETE") {
return generateModifyForm(url, verb);
}
else if (verb == "WS") {
} else if (verb == "WS") {
return generateWSPage(url);
}
}
@ -845,6 +889,7 @@ module.exports = {
addMember,
removeMember,
setStructurePrivacy,
setStructurePublic,
createRoute,
getRoutes,
getRoute,
@ -877,5 +922,5 @@ module.exports = {
getMostRecentLogIdByRoute,
createScaffoldPage,
getLatestScaffoldPage,
updateScaffoldPage
updateScaffoldPage,
};

View file

@ -320,7 +320,12 @@ wsRouter.ws("*", (ws, req) => {
}
const { route } = found;
if (!model.canAccessStructure(req.session && req.session.userId, route.structure_id)) {
if (
!model.canAccessStructure(
req.session && req.session.userId,
route.structure_id,
)
) {
return ws.close(1008, "WebSocket route not found");
}
req.params = found.params;
@ -382,7 +387,11 @@ buildRoutes();
// crafted ?next= can't bounce someone to another origin (open redirect). A
// leading `//` is protocol-relative and would escape our origin, so reject it.
function safeNext(next) {
if (typeof next !== "string" || !next.startsWith("/") || next.startsWith("//")) {
if (
typeof next !== "string" ||
!next.startsWith("/") ||
next.startsWith("//")
) {
return null;
}
return next;
@ -1000,14 +1009,18 @@ 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".
// An unchecked checkbox sends no field at all, so absence means "off".
const wantPrivate = req.body.private === "on" || req.body.private === "1";
const wantPublic = req.body.public === "on" || req.body.public === "1";
if (wantPrivate && !req.session.userId) {
return res
.status(403)
.send("log in before making a structure private, or you'll lock everyone out");
.send(
"log in before making a structure private, or you'll lock everyone out",
);
}
model.setStructurePrivacy(structId, wantPrivate, req.session.userId);
model.setStructurePublic(structId, wantPublic);
model.updateStruct({
...struct,
@ -1093,9 +1106,13 @@ app.all("*", async (req, res) => {
// logged-in non-members still 404.
if (!model.canAccessStructure(req.session.userId, route.structure_id)) {
if (!req.session.userId && verb === "GET") {
return res.redirect("/login?next=" + encodeURIComponent(req.originalUrl));
return res.redirect(
"/login?next=" + encodeURIComponent(req.originalUrl),
);
}
return res.status(404).json({ success: false, message: "Path not found" });
return res
.status(404)
.json({ success: false, message: "Path not found" });
}
// Minimal, handcrafted view of the logged-in Bliss user for user routes.

View file

@ -0,0 +1,14 @@
-- Login-required by default. Until now a structure was reachable by anyone
-- unless it was made members-only (`private = 1`). We flip that baseline: every
-- structure now requires a logged-in user to view, and `public = 1` is the
-- explicit opt-out that puts a structure back on the open web (no login).
--
-- The two flags are independent axes, resolved by canAccessStructure in db.js:
-- public = 1 -> anyone, no login (opt-in, with a warning)
-- public = 0, private = 0 -> any logged-in user (the new default)
-- public = 0, private = 1 -> invited members only (unchanged)
ALTER TABLE structures ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
-- squirtify (prod id 28) was live on the open web under the old public-by-default
-- world; keep it reachable without login so nothing breaks for its users.
UPDATE structures SET public = 1 WHERE id = 28;

View file

@ -27,9 +27,19 @@
<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="public" name="public" <%= it.structure.public ? "checked" : "" %>
_="on change toggle .hidden on #public-warning">
<label for="public">Public (anyone on the open web, no login)</label>
</div>
<div id="public-warning" class="text-xs text-red-700 pl-1 <%= it.structure.public ? "" : "hidden" %>">
⚠️ This puts the structure on the open web — anyone can view it
without logging in. Every other structure requires a login by
default; only check this if you truly mean to make it public.
</div>
<div class="field-row">
<input type="checkbox" id="private" name="private" <%= it.structure.private ? "checked" : "" %>>
<label for="private">Private (only invited members)</label>
<label for="private">Members only (invited members, stricter than login)</label>
</div>
<button id="save-btn" type="submit">Save</button>
<div id="result"></div>