2026-08-02 23:00:26 -04:00
|
|
|
// plumbing.js — read-only JSON utility API.
|
|
|
|
|
//
|
|
|
|
|
// This is plumbing, not part of the product surface. The workshop UI renders
|
2026-08-22 11:29:04 -04:00
|
|
|
// 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.
|
2026-08-02 23:00:26 -04:00
|
|
|
|
|
|
|
|
const express = require("express");
|
|
|
|
|
const model = require("./db");
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
2026-08-22 11:29:04 -04:00
|
|
|
function currentUserId(req) {
|
|
|
|
|
return req.session && req.session.userId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function canAccess(req, structureId) {
|
|
|
|
|
return model.canAccessStructure(currentUserId(req), structureId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:51:07 -04:00
|
|
|
// Private structures are invisible through the plumbing too. Every structure-
|
|
|
|
|
// scoped route carries :id, so one param guard covers them all; a caller who
|
2026-08-22 11:29:04 -04:00
|
|
|
// isn't allowed to see it gets a 404, same as the workshop.
|
2026-08-19 09:51:07 -04:00
|
|
|
router.param("id", (req, res, next, id) => {
|
2026-08-22 11:29:04 -04:00
|
|
|
if (!canAccess(req, id)) {
|
2026-08-19 09:51:07 -04:00
|
|
|
return res.status(404).json({ error: "not found" });
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-02 23:00:26 -04:00
|
|
|
// Wrap a handler so thrown errors come back as JSON instead of an HTML stack.
|
2026-08-22 11:29:04 -04:00
|
|
|
// 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.
|
2026-08-02 23:00:26 -04:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 11:29:04 -04:00
|
|
|
// ---- 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 09:51:07 -04:00
|
|
|
// All structures the caller is allowed to see (private ones they aren't a
|
|
|
|
|
// member of are omitted).
|
2026-08-02 23:00:26 -04:00
|
|
|
router.get(
|
|
|
|
|
"/structures",
|
2026-08-19 09:51:07 -04:00
|
|
|
json((req) =>
|
2026-08-22 11:29:04 -04:00
|
|
|
model.getStructures().filter((s) => canAccess(req, s.id)),
|
2026-08-19 09:51:07 -04:00
|
|
|
),
|
2026-08-02 23:00:26 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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),
|
2026-08-22 11:29:04 -04:00
|
|
|
dbs: visibleDbs(req, req.params.id),
|
2026-08-02 23:00:26 -04:00
|
|
|
files: model.getFilesForStruct(req.params.id),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-22 11:29:04 -04:00
|
|
|
// 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.
|
2026-08-02 23:00:26 -04:00
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/routes/:routeId",
|
2026-08-22 11:29:04 -04:00
|
|
|
json((req) => routeFor(req, req.params.routeId, req.params.id)),
|
|
|
|
|
);
|
|
|
|
|
router.get(
|
|
|
|
|
"/routes/:routeId",
|
|
|
|
|
json((req) => routeFor(req, req.params.routeId)),
|
2026-08-02 23:00:26 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// One template, including content + test_object.
|
|
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/templates/:templateId",
|
2026-08-22 11:29:04 -04:00
|
|
|
json((req) => templateFor(req, req.params.templateId, req.params.id)),
|
2026-08-02 23:00:26 -04:00
|
|
|
);
|
|
|
|
|
router.get(
|
2026-08-22 11:29:04 -04:00
|
|
|
"/templates/:templateId",
|
|
|
|
|
json((req) => templateFor(req, req.params.templateId)),
|
2026-08-02 23:00:26 -04:00
|
|
|
);
|
|
|
|
|
|
2026-08-22 11:29:04 -04:00
|
|
|
// One db (scoped to the structure), including its library source.
|
2026-08-02 23:00:26 -04:00
|
|
|
router.get(
|
2026-08-22 11:29:04 -04:00
|
|
|
"/structures/:id/dbs/:dbId",
|
|
|
|
|
json((req) => dbFor(req, req.params.id, req.params.dbId)),
|
2026-08-02 23:00:26 -04:00
|
|
|
);
|
|
|
|
|
|
2026-08-03 00:27:24 -04:00
|
|
|
// Version history (newest first) for a route handler, template, or db library.
|
2026-08-22 11:29:04 -04:00
|
|
|
// 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.
|
2026-08-03 00:27:24 -04:00
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/routes/:routeId/versions",
|
2026-08-22 11:29:04 -04:00
|
|
|
json((req) =>
|
|
|
|
|
routeFor(req, req.params.routeId, req.params.id) &&
|
|
|
|
|
model.getVersions("route", req.params.routeId),
|
|
|
|
|
),
|
2026-08-03 00:27:24 -04:00
|
|
|
);
|
|
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/templates/:templateId/versions",
|
2026-08-22 11:29:04 -04:00
|
|
|
json((req) =>
|
|
|
|
|
templateFor(req, req.params.templateId, req.params.id) &&
|
|
|
|
|
model.getVersions("template", req.params.templateId),
|
|
|
|
|
),
|
2026-08-03 00:27:24 -04:00
|
|
|
);
|
|
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/dbs/:dbId/versions",
|
2026-08-22 11:29:04 -04:00
|
|
|
json((req) =>
|
|
|
|
|
dbFor(req, req.params.id, req.params.dbId) &&
|
|
|
|
|
model.getVersions("db", req.params.dbId),
|
|
|
|
|
),
|
2026-08-03 00:27:24 -04:00
|
|
|
);
|
|
|
|
|
|
2026-08-19 09:51:07 -04:00
|
|
|
// 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.
|
2026-08-03 00:27:24 -04:00
|
|
|
router.get(
|
|
|
|
|
"/versions/:versionId",
|
2026-08-19 09:51:07 -04:00
|
|
|
json((req) => {
|
|
|
|
|
const version = model.getVersion(req.params.versionId);
|
|
|
|
|
if (!version) return null;
|
2026-08-22 11:29:04 -04:00
|
|
|
if (!canAccess(req, version.structure_id)) return null;
|
2026-08-19 09:51:07 -04:00
|
|
|
return version;
|
|
|
|
|
}),
|
2026-08-03 00:27:24 -04:00
|
|
|
);
|
|
|
|
|
|
2026-08-22 11:29:04 -04:00
|
|
|
// Logs for a route. ?since=<id> returns only newer rows (for polling). The route
|
|
|
|
|
// is owner-checked first so logs never leak from a structure you can't see.
|
2026-08-02 23:00:26 -04:00
|
|
|
router.get(
|
|
|
|
|
"/structures/:id/routes/:routeId/logs",
|
|
|
|
|
json((req) => {
|
2026-08-22 11:29:04 -04:00
|
|
|
if (!routeFor(req, req.params.routeId, req.params.id)) return null;
|
2026-08-02 23:00:26 -04:00
|
|
|
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;
|