diff --git a/backend/services/app.service.js b/backend/services/app.service.js index 0919779..f5194a2 100644 --- a/backend/services/app.service.js +++ b/backend/services/app.service.js @@ -1,6 +1,7 @@ const urlJoin = require('url-join'); const { AppService } = require('@activitypods/app'); const CONFIG = require('../config/config'); +const { requestUri } = require('./base-path'); // `GET /app` has two personalities, discriminated by the request's `JsonLdContext` header. // @@ -100,12 +101,12 @@ module.exports = { // The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app` // serve this document (the configured client_id is the actor uri; `/app` remains // a valid alias), so echo the request path rather than hardcoding one. - // `req.url` is rewritten to the alias remainder by moleculer-web; the raw path - // survives in `originalUrl`. - const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0]; + // `req.url` is rewritten to the alias remainder by moleculer-web; the raw path, base + // path included, survives in `originalUrl`. + const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0]; return { '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], - client_id: urlJoin(CONFIG.HOME_URL, requestPath), + client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'), redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')], client_name: CONFIG.APP_NAME, client_uri: CONFIG.FRONT_URL diff --git a/backend/services/base-path.js b/backend/services/base-path.js new file mode 100644 index 0000000..d315570 --- /dev/null +++ b/backend/services/base-path.js @@ -0,0 +1,35 @@ +// The path the backend is served under, read from its base URL (SEMAPPS_HOME_URL): `/` when the +// backend owns its origin (http://localhost:3003 locally), `/api/` when it shares the front end's +// origin (https:///api/, the Coolify layout, where one base URL per environment keeps pull +// request previews on a single host). The proxy in front passes the path through unchanged; SemApps +// mounts every route it adds under the base path, except the `/.well-known` ones, which stay at the +// origin root. Festipod's own routes and its public-surface allow-list follow the same split. + +const urlJoin = require('url-join'); + +/** basePathOf(baseUrl) → the base URL's path, always ending with `/` (`/` when it has none or is unset). */ +function basePathOf(baseUrl) { + if (!baseUrl) return '/'; + const { pathname } = new URL(baseUrl); + return pathname.endsWith('/') ? pathname : `${pathname}/`; +} + +/** underBasePath(basePath, path) → the absolute `path` (e.g. `/catalog`) as mounted under `basePath`. */ +function underBasePath(basePath, path) { + return basePath + path.replace(/^\/+/, ''); +} + +/** + * requestUri(baseUrl, requestPath) → the absolute URI a request was made to. `requestPath` is the raw + * path the gateway received (moleculer-web's `originalUrl`), base path included: the base path is + * taken off before joining it to the base URL, which would otherwise repeat it. Joining (rather than + * resolving with `new URL`) keeps the base URL exactly as written — host case, explicit port — so the + * result matches the URIs the backend mints from the same base URL, its actor's first. + */ +function requestUri(baseUrl, requestPath) { + const basePath = basePathOf(baseUrl); + const relative = requestPath.startsWith(basePath) ? requestPath.slice(basePath.length - 1) : requestPath; + return urlJoin(baseUrl, relative); +} + +module.exports = { basePathOf, underBasePath, requestUri }; diff --git a/backend/services/base-path.test.js b/backend/services/base-path.test.js new file mode 100644 index 0000000..b07aa74 --- /dev/null +++ b/backend/services/base-path.test.js @@ -0,0 +1,55 @@ +const { test, expect, describe } = require('bun:test'); +const { basePathOf, underBasePath, requestUri } = require('./base-path'); + +describe('basePathOf — the path the backend is served under', () => { + test('a backend owning its origin is served at the root, with or without a trailing slash', () => { + expect(basePathOf('http://localhost:3003')).toBe('/'); + expect(basePathOf('http://localhost:3003/')).toBe('/'); + }); + + test('a backend sharing the front end origin is served under its path, always ending with a slash', () => { + expect(basePathOf('https://festipod.dev.reconnexion.coop/api/')).toBe('/api/'); + expect(basePathOf('https://festipod.dev.reconnexion.coop/api')).toBe('/api/'); + }); + + test('an unset base URL reads as the root', () => { + expect(basePathOf(undefined)).toBe('/'); + expect(basePathOf('')).toBe('/'); + }); +}); + +describe('underBasePath — where a route is mounted', () => { + test('at the root, a route keeps its path', () => { + expect(underBasePath('/', '/catalog')).toBe('/catalog'); + expect(underBasePath('/', '/as/actor/app')).toBe('/as/actor/app'); + }); + + test('under a base path, a route is prefixed by it, without a doubled slash', () => { + expect(underBasePath('/api/', '/catalog')).toBe('/api/catalog'); + expect(underBasePath('/api/', 'as/actor/app')).toBe('/api/as/actor/app'); + }); +}); + +describe('requestUri — the URI a request was made to', () => { + test('at the root, the request path is appended to the origin', () => { + expect(requestUri('http://localhost:3003', '/as/actor/app')).toBe('http://localhost:3003/as/actor/app'); + expect(requestUri('http://localhost:3003/', '/app')).toBe('http://localhost:3003/app'); + }); + + test('under a base path, the raw request path already carries it and is not prefixed twice', () => { + expect(requestUri('https://festipod.dev.reconnexion.coop/api/', '/api/as/actor/app')).toBe( + 'https://festipod.dev.reconnexion.coop/api/as/actor/app' + ); + expect(requestUri('https://festipod.dev.reconnexion.coop/api', '/api/app')).toBe( + 'https://festipod.dev.reconnexion.coop/api/app' + ); + }); + + test('the base URL is kept exactly as written — the same URI the backend mints its actor under', () => { + const urlJoin = require('url-join'); + for (const baseUrl of ['http://localhost:80', 'https://Festipod.Example/api/', 'https://host:443/api']) { + const path = `${basePathOf(baseUrl)}as/actor/app`; + expect(requestUri(baseUrl, path)).toBe(urlJoin(baseUrl, 'as/actor/app')); + } + }); +}); diff --git a/backend/services/client-id-doc.service.js b/backend/services/client-id-doc.service.js index 075cff0..599287f 100644 --- a/backend/services/client-id-doc.service.js +++ b/backend/services/client-id-doc.service.js @@ -1,5 +1,6 @@ const urlJoin = require('url-join'); const CONFIG = require('../config/config'); +const { requestUri } = require('./base-path'); // Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual // personality, discriminated by the request's `JsonLdContext` header — same contract as @@ -28,11 +29,12 @@ module.exports = { if (isOidcClientIdDocument) { // The client_id must equal the fetched URI exactly. `req.url` is rewritten to - // the alias remainder by moleculer-web; the raw path survives in `originalUrl`. - const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0]; + // the alias remainder by moleculer-web; the raw path, base path included, survives + // in `originalUrl`. + const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0]; return { '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], - client_id: urlJoin(CONFIG.HOME_URL, requestPath), + client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'), redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')], client_name: CONFIG.APP_NAME, client_uri: CONFIG.FRONT_URL diff --git a/backend/services/core/core.service.js b/backend/services/core/core.service.js index a404b3c..47d4188 100644 --- a/backend/services/core/core.service.js +++ b/backend/services/core/core.service.js @@ -2,7 +2,11 @@ const path = require('path'); const { CoreService } = require('@semapps/core'); const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies'); const CONFIG = require('../../config/config'); -const { publicSurfaceGate } = require('../public-surface'); +const { createPublicSurfaceGate } = require('../public-surface'); +const { basePathOf, underBasePath } = require('../base-path'); + +// Festipod's own routes live under the base path, as SemApps' do (see base-path.js). +const BASE_PATH = basePathOf(CONFIG.HOME_URL); module.exports = { mixins: [CoreService], @@ -25,10 +29,10 @@ module.exports = { // catch-all among them, which with `webacl: false` would serve the whole `settings` dataset, // the app's private key included): only the allow-listed public endpoints pass, anything else // is a 404 for every caller. See public-surface.js. - use: [publicSurfaceGate], + use: [createPublicSurfaceGate(BASE_PATH)], routes: [ { - path: '/app', + path: underBasePath(BASE_PATH, '/app'), aliases: { 'GET /': { action: 'app.get', @@ -46,7 +50,7 @@ module.exports = { } }, { - path: '/as/actor/app', + path: underBasePath(BASE_PATH, '/as/actor/app'), aliases: { 'GET /': { // Served by a standalone service (not `app.get`): the Solid-OIDC client_id @@ -64,7 +68,7 @@ module.exports = { } }, { - path: '/catalog', + path: underBasePath(BASE_PATH, '/catalog'), aliases: { 'GET /': 'catalog.discover' }, diff --git a/backend/services/public-surface.js b/backend/services/public-surface.js index dcc9bb5..aa831cb 100644 --- a/backend/services/public-surface.js +++ b/backend/services/public-surface.js @@ -25,6 +25,10 @@ // 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 @@ -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 }; diff --git a/backend/services/public-surface.test.js b/backend/services/public-surface.test.js index 72d1e2b..ae87ca5 100644 --- a/backend/services/public-surface.test.js +++ b/backend/services/public-surface.test.js @@ -1,16 +1,33 @@ const path = require('path'); const { test, expect, describe, afterEach } = require('bun:test'); const { ServiceBroker } = require('moleculer'); -const { isPublicRequest, publicSurfaceGate } = require('./public-surface'); +const { isPublicRequest, createPublicSurfaceGate } = require('./public-surface'); +const { basePathOf } = require('./base-path'); const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20'; describe('the core service mounts the gate in front of every route', () => { - test('core.service.js wires publicSurfaceGate as a global gateway middleware, and no SPARQL endpoint', () => { + test('core.service.js wires the gate, for its own base path, as a global gateway middleware, and no SPARQL endpoint', () => { const core = require('./core/core.service.js'); - expect(core.settings.api.use).toContain(publicSurfaceGate); + const CONFIG = require('../config/config'); + const base = basePathOf(CONFIG.HOME_URL); + expect(core.settings.api.use).toHaveLength(1); + const [gate] = core.settings.api.use; + expect(gate.name).toBe('publicSurfaceGate'); + let passed; + gate({ method: 'GET', parsedUrl: `${base}catalog`, headers: {} }, {}, err => (passed = err === undefined)); + expect(passed).toBe(true); + gate({ method: 'GET', parsedUrl: `${base}key`, headers: {} }, {}, err => (passed = err === undefined)); + expect(passed).toBe(false); expect(core.settings.sparqlEndpoint).toBe(false); }); + + test("core.service.js mounts Festipod's own routes under its base path", () => { + const core = require('./core/core.service.js'); + const CONFIG = require('../config/config'); + const base = basePathOf(CONFIG.HOME_URL); + expect(core.settings.api.routes.map(route => route.path)).toEqual([`${base}app`, `${base}as/actor/app`, `${base}catalog`]); + }); }); describe('isPublicRequest — the allow-list', () => { @@ -89,7 +106,56 @@ describe('isPublicRequest — the allow-list', () => { }); }); +describe('isPublicRequest — under a base path (the Coolify layout: the backend at /api/ on the front end origin)', () => { + const BASE = '/api/'; + + test('every public endpoint is public under the base path, the /.well-known ones at the origin root', () => { + for (const [method, p] of [ + ['GET', '/api/app'], + ['GET', '/api/as/actor/app'], + ['GET', '/api/catalog'], + ['GET', `/api/public-key/${UUID}`], + ['GET', `/api/interop/access-need-group/${UUID}`], + ['GET', `/api/interop/access-need/${UUID}`], + ['GET', '/api/nodeinfo/2.1'], + ['POST', '/api/as/actor/app/inbox'], + ['POST', `/api/.webhooks/${UUID}`], + ['GET', '/.well-known/context.jsonld'], + ['GET', '/.well-known/nodeinfo'] + ]) { + expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} true`); + } + }); + + test('the same endpoints outside the base path, and the /.well-known ones under it, are refused', () => { + for (const [method, p] of [ + ['GET', '/app'], + ['GET', '/as/actor/app'], + ['GET', '/catalog'], + ['GET', `/public-key/${UUID}`], + ['POST', '/as/actor/app/inbox'], + ['POST', `/.webhooks/${UUID}`], + ['GET', '/api/.well-known/context.jsonld'], + ['GET', '/api/.well-known/nodeinfo'], + ['GET', '/api'], + ['GET', '/apiapp'], + ['GET', `/apipublic-key/${UUID}`] + ]) { + expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} false`); + } + }); + + test('what is refused at the root is refused under the base path', () => { + for (const p of ['/', '/key', `/key/${UUID}`, '/sparql', '/proxy', `/interop/access-grant/${UUID}`, '/as/actor/app/outbox', `/public-key/../key/${UUID}`, `//public-key/${UUID}`]) { + expect(`GET /api${p} ${isPublicRequest('GET', `/api${p}`, BASE)}`).toBe(`GET /api${p} false`); + } + expect(isPublicRequest('PUT', '/api/catalog', BASE)).toBe(false); + expect(isPublicRequest('POST', '/api/app', BASE)).toBe(false); + }); +}); + describe('publicSurfaceGate — the middleware', () => { + const publicSurfaceGate = createPublicSurfaceGate('/'); const run = req => { let passed; publicSurfaceGate(req, {}, err => { @@ -163,7 +229,7 @@ describe('the gate covers routes added at runtime — the LDP catch-all that ser }); test('with the gate: the key container, the root and any write are 404; a public key is served', async () => { - const base = await startGateway([publicSurfaceGate]); + const base = await startGateway([createPublicSurfaceGate('/')]); expect(await status(base, 'GET', '/key')).toBe(404); expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404); expect(await status(base, 'GET', '/')).toBe(404); diff --git a/scripts/e2e-rights-matrix.ts b/scripts/e2e-rights-matrix.ts index 1c14e6b..d4a5212 100644 --- a/scripts/e2e-rights-matrix.ts +++ b/scripts/e2e-rights-matrix.ts @@ -774,7 +774,10 @@ async function tracked(): Promise { */ async function publicSurface(): Promise { const G = 'backend public surface (anonymous GET, status codes only)'; - const status = async (path: string): Promise => (await http('GET', `${BACKEND}${path}`)).status; + // A path is under the backend's base URL; a full URL is taken as is. + const status = async (target: string): Promise => (await http('GET', /^https?:\/\//.test(target) ? target : `${BACKEND}${target}`)).status; + // The /.well-known endpoints sit at the backend ORIGIN's root whatever its base path (`/api` on Coolify, backend/services/base-path.js). + const atOriginRoot = (path: string): string => `${new URL(BACKEND).origin}${path}`; const pathOf = (uri: string | undefined, what: string): string => { const u = need(uri, what); if (!u.startsWith(`${BACKEND}/`)) throw new Error(`${what} is not on the backend (${u})`); @@ -794,12 +797,12 @@ async function publicSurface(): Promise { pathOf(linkId(actor?.['publicKey']), 'the app actor\'s public key (GET /as/actor/app)'), groupPath, pathOf(linkId(arrayOf(group?.['interop:hasAccessNeed'])[0]), 'an access need (the access-need group)'), - '/.well-known/context.jsonld', '/.well-known/nodeinfo', '/nodeinfo/2.1', + atOriginRoot('/.well-known/context.jsonld'), atOriginRoot('/.well-known/nodeinfo'), '/nodeinfo/2.1', ]; const codes = await Promise.all(paths.map(status)); return { ok: codes.every(c => c === 200), actual: paths.map((p, i) => `${shown(p)} ${codes[i]}`).join(', ') }; }); - const webfinger = `/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`; + const webfinger = atOriginRoot(`/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`); const refusedPaths = [ '/', '/key', '/sparql', '/sparql?query=ASK%7B%7D', '/settings', '/proxy', '/_acl', '/.webhooks', '/interop/application-registration', '/interop/access-grant', '/interop/data-grant',