// The backend's whole HTTP surface, as an allow-list checked in front of every route. // // WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the // resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP // catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /`, // `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's // PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds // (`/interop/…`), the webhook listeners. A signed request is no better: 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. // // 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: /^\/\.well-known\/context\.jsonld$/ }, { methods: READ, path: /^\/\.well-known\/nodeinfo$/ }, { methods: READ, path: /^\/nodeinfo\/2\.1$/ }, { methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ }, { methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) } ]; /** * isPublicRequest(method, path) → boolean. True only when `method` (upper case) on `path` (the * request path without its query string, as the gateway routes it) is in `PUBLIC_ENDPOINTS`. */ function isPublicRequest(method, path) { if (typeof method !== 'string' || typeof path !== 'string') return false; const verb = method.toUpperCase(); return PUBLIC_ENDPOINTS.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(path)); } /** 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; } /** * 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 request in * `PUBLIC_ENDPOINTS` through; answers any other with 404 before authentication or any action runs. */ 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))) return next(); return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND')); } module.exports = { isPublicRequest, publicSurfaceGate, PUBLIC_ENDPOINTS };