Serve the backend under a base path: one URL per environment, the backend under /api

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.
This commit is contained in:
Sylvain Duchesne
2026-10-19 16:44:09 +02:00
parent 8d0670a637
commit 738d59596a
8 changed files with 214 additions and 33 deletions
+29 -14
View File
@@ -25,6 +25,10 @@
// 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
@@ -46,21 +50,29 @@ const PUBLIC_ENDPOINTS = [
{ 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}$`) }
];
/** 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) → 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`.
* 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) {
function isPublicRequest(method, path, basePath = '/') {
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));
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). */
@@ -71,14 +83,17 @@ function routedPathOf(req) {
}
/**
* 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.
* 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 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'));
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, publicSurfaceGate, PUBLIC_ENDPOINTS };
module.exports = { isPublicRequest, createPublicSurfaceGate, PUBLIC_ENDPOINTS, ROOT_ENDPOINTS };