738d59596a
The test deployment keeps one base URL per environment, as the sibling ActivityPods apps do, so a pull-request preview lives on a single host: the app at the root, the backend under /api on the same host, its /.well-known routes at the origin root, the proxy passing paths through unchanged. SemApps already works that way — it mounts every route it adds under the base URL's path and keeps /.well-known at the root — but Festipod's own pieces were written for a backend at the root of its own origin: its routes (/app, /as/actor/app, /catalog) were mounted at fixed paths, the public-surface allow-list matched fixed paths (so under /api every request would have been refused), and the Solid-OIDC client_id joined the raw request path to the base URL, which would have repeated the base path (/api/api/...). base-path.js derives the base path from SEMAPPS_HOME_URL; the routes are mounted under it; the allow-list matches paths relative to it, the two /.well-known endpoints against the full path; the client_id is the requested URI rebuilt from the base URL as written, equal byte for byte to the actor URI and to what the front end derives. At the root every gate decision is identical to before (compared over ~8M method/path cases). The rights matrix probes /.well-known at the backend's origin root. Verified: bun test (424 pass); the full gate at the root; and the built images behind a local proxy routing /api and /.well-known unstripped with the Host header kept — login flow, rights matrix 98/98, revocation.
100 lines
5.9 KiB
JavaScript
100 lines
5.9 KiB
JavaScript
// 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 /<any path>`,
|
|
// `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/<id> the app actor's PUBLIC key — every pod verifying an app signature
|
|
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
|
|
// 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/<id> 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 };
|