// The backend's whole HTTP surface, as an allow-list checked in front of every route. // // WHY. CoreService mounts routes Festipod never asked for — above all the LDP catch-all // (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /`), over the dataset that // holds the app actor's PRIVATE key (`/key`), the user registrations and grants the app holds // (`/interop/…`), the catalog store and the ledgers. WebACL filters what those routes serve (the main // dataset is a secure one, see core.service.js), so each resource is only as private as the rights // its container gave it; this gate does not depend on those rights being right. A signed request is // no better than an anonymous one here: any pod user can sign one. Rather than chase each route // (deny-list), the gateway serves ONLY what a caller legitimately needs and refuses everything else // with a 404 — for every caller, anonymous or signed, since none needs more over HTTP: the backend's // own services reach its data through internal calls, never through these routes. // // WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs): // GET /app the app description — the Festipod front end and the pod (install) // GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod // GET /catalog the public catalog — the Festipod front end // GET /public-key/ the app actor's PUBLIC key — every pod verifying an app signature // GET /interop/access-need-group/, /interop/access-need/ // what the app asks for — the pod, at install and consent // GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod // GET /.well-known/nodeinfo, /nodeinfo/2.1 // server discovery (links the app actor) — the pod // POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform) // POST /.webhooks/ the pods' notifications to the app's watcher (see emitted-activity.js) // HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces. // // WHERE. The paths above are relative to the backend's base path (`/` locally, `/api/` on Coolify — // see base-path.js), except the two `/.well-known` ones, which SemApps mounts at the origin root // whatever the base path. A path outside the base path is refused like any other. // // Everything else is refused, notably: `/key` (private keys), `/` and every other container listing, // `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the // signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app // actor's other collections, and every write through the LDP routes. // moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency). const { MoleculerClientError } = require('moleculer').Errors; // One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no // encoded separator or traversal; no empty segment. const ID = '[A-Za-z0-9][A-Za-z0-9_-]*'; const READ = ['GET', 'HEAD']; const PUBLIC_ENDPOINTS = [ { methods: READ, path: /^\/app$/ }, { methods: READ, path: /^\/as\/actor\/app$/ }, { methods: READ, path: /^\/catalog$/ }, { methods: READ, path: new RegExp(`^/public-key/${ID}$`) }, { methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) }, { methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) }, { methods: READ, path: /^\/nodeinfo\/2\.1$/ }, { methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ }, { methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) } ]; /** The endpoints at the origin root, whatever the base path. */ const ROOT_ENDPOINTS = [ { methods: READ, path: /^\/\.well-known\/context\.jsonld$/ }, { methods: READ, path: /^\/\.well-known\/nodeinfo$/ } ]; /** * isPublicRequest(method, path, basePath = '/') → boolean. True only when `method` (upper case) on * `path` (the request path without its query string, as the gateway routes it) is in `ROOT_ENDPOINTS`, * or lies under `basePath` (as `basePathOf` returns it) and, relative to it, is in `PUBLIC_ENDPOINTS`. */ function isPublicRequest(method, path, basePath = '/') { if (typeof method !== 'string' || typeof path !== 'string') return false; const verb = method.toUpperCase(); const listed = (endpoints, p) => endpoints.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(p)); if (listed(ROOT_ENDPOINTS, path)) return true; if (!path.startsWith(basePath)) return false; return listed(PUBLIC_ENDPOINTS, path.slice(basePath.length - 1)); } /** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */ function routedPathOf(req) { if (typeof req.parsedUrl === 'string') return req.parsedUrl; const raw = String(req.originalUrl || req.url || '').split('?')[0]; return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw; } /** * createPublicSurfaceGate(basePath = '/') → publicSurfaceGate(req, res, next), a moleculer-web global * middleware (`api.use`, which the gateway puts in front of every route, including the ones services * add at runtime). Passes a public request (`isPublicRequest` under `basePath`) through; answers any * other with 404 before authentication or any action runs. */ function createPublicSurfaceGate(basePath = '/') { return function publicSurfaceGate(req, res, next) { const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method; if (isPublicRequest(method, routedPathOf(req), basePath)) return next(); return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND')); }; } module.exports = { isPublicRequest, createPublicSurfaceGate, PUBLIC_ENDPOINTS, ROOT_ENDPOINTS };