// 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, // 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 allowed to see it gets a 404, same as the workshop. router.param("id", (req, res, next, 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 { const body = handler(req); if (body === undefined || body === null) { return res.status(404).json({ error: "not found" }); } return res.json(body); } catch (e) { return res.status(500).json({ error: String(e), stack: e.stack }); } }; } // ---- 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) => canAccess(req, s.id)), ), ); // One structure with everything the sidebar shows, in one call. router.get( "/structures/:id", json((req) => { const structure = model.getStructure(req.params.id); if (!structure) return null; return { structure, routes: model.getRoutes(req.params.id), templates: model.getTemplates(req.params.id), dbs: visibleDbs(req, req.params.id), files: model.getFilesForStruct(req.params.id), }; }), ); // 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) => 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) => 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) => 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. 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) => routeFor(req, req.params.routeId, req.params.id) && model.getVersions("route", req.params.routeId), ), ); router.get( "/structures/:id/templates/:templateId/versions", 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) => 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 // by :id, so it carries its own access check against the version's structure. router.get( "/versions/:versionId", json((req) => { const version = model.getVersion(req.params.versionId); if (!version) return null; if (!canAccess(req, version.structure_id)) return null; return version; }), ); // 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 ? model.getNewLogsByRoute(req.params.routeId, since) : model.getLogsByRoute(req.params.routeId); return { logs, since: model.getMostRecentLogIdByRoute(req.params.routeId) }; }), ); module.exports = router;