3 Commits

Author SHA1 Message Date
Sylvain Duchesne a28a318c24 Deploy to the Coolify test server: docker-compose.coolify.yml
The stack Coolify builds from the repository, on the test server only for
now, modelled on the sibling ActivityPods apps: the app (root Dockerfile), the
backend (backend/Dockerfile) and a per-stack Redis; Fuseki is the shared one,
reached over the external coolify network, with a pair of datasets per stack.

One base URL per environment: the app at https://festipod.dev.reconnexion.coop,
the backend's Domains https://<host>/api,https://<host>/.well-known, with
Coolify's Strip Prefixes OFF. The Pod provider offered in one click defaults
to https://mypod.store, as for the sibling dev apps.

The deployment leaf no longer claims that nothing is deployed with this shape
and that no Coolify config exists; it records the stack, the base-path layout,
both images' build traps and the first-boot ones (set the domains before the
first deployment; restart the backend once so /app carries the access-need
group).
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 738d59596a 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.
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 8d0670a637 The backend image starts: approve the native builds, compile sharp against the system libvips
Built and run for the first time (until now the dockerized path had only been
checked with `docker compose config`): the image built, and the container
exited at start with `Cannot find module '../build/Release/sharp-linux-x64.node'`.
pnpm 10 skips dependency build scripts unless they are approved, so sharp,
bcrypt and sodium-native were installed without their binaries. A local
node_modules can carry binaries from an earlier manual build, which is how it
stayed invisible. `pnpm.onlyBuiltDependencies` now approves the three.

Approved, sharp's installer then downloaded its prebuilt libvips and binary
from GitHub, and that download timed out on every in-build attempt (3 of 3),
failing the build, while the same download from a plain container was
instantaneous. With libvips-dev installed, sharp detects a system libvips and
compiles itself instead; npm_config_nodedir points node-gyp at the headers the
image already ships. Verified with GitHub and nodejs.org blackholed: the build
passes, sharp loads against libvips 8.14.1, bcrypt falls back to compiling.

Cost: a larger image; a multi-stage build keeping only libvips42 at runtime
would drop several hundred MB.
2026-10-19 16:44:09 +02:00
13 changed files with 352 additions and 43 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ type: _overview
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
triggers:
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", "backend/Dockerfile", "docker-compose*.yml", "backend/services/base-path.js", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
---
# Tech stack
@@ -18,4 +18,4 @@ The project's stack and tooling. Guiding principle: **Bun-first** — Bun replac
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
- [[knowledge_deployment]] — the Coolify test stack (one base URL, backend under /api), both images, first-boot traps, prod served from src/, `portless` in dev
@@ -1,14 +1,32 @@
---
type: knowledge
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-08-17
summary: Deployment — the Coolify test stack (app + backend + Redis, shared Fuseki), one base URL with the backend under /api (Strip Prefixes OFF), both images' build traps, first-boot traps, the data-layer git pin, the shared wallet as env config
last_checked: 2026-10-19
---
# Deployment & infra
Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end.
## The Coolify test stack
## Dockerfile
`docker-compose.coolify.yml` is what Coolify (build pack *Docker Compose*) builds and runs from the git repository, on the test server only for now: `frontend` (the root `Dockerfile`, below), `backend` (`backend/Dockerfile`, Moleculer + `@activitypods/app`) and a per-stack Redis. Fuseki is the one shared with the other ActivityPods apps of the Coolify instance (`shared-infra`): only the backend joins the external `coolify` network to reach it, with a pair of datasets of its own named from `DATASET_PREFIX` and the backend's service name (a second non-preview stack on the same Fuseki needs its own prefix). The variables Coolify must carry are listed at the top of the compose file; `SERVICE_FQDN_*` / `SERVICE_NAME_*` are generated by Coolify.
**One base URL per environment** (user decision 2026-10-19, so a pull-request preview lives on a single host, like the sibling apps): the app at `https://festipod.dev.reconnexion.coop/`, the backend on the same host under `/api` — its Domains field is `https://<host>/api,https://<host>/.well-known`. Coolify's **Strip Prefixes must be OFF** (it is on the sibling apps): the backend mounts its routes under `/api` itself, read from `SEMAPPS_HOME_URL=https://<host>/api/`, and the front end is pointed at `VITE_BACKEND_URL=https://<host>/api`. With the prefix stripped every backend request is refused (404).
**The backend is base-path aware** (`backend/services/base-path.js`), because SemApps is: it mounts every route it adds under the base URL's path (LDP resources, the app actor and its inbox, public keys, access needs, webhooks, `/nodeinfo/2.1`) but keeps the `/.well-known` ones (`context.jsonld`, `nodeinfo`, `solid`, `void`) at the origin root. Festipod's own routes (`/app`, `/as/actor/app`, `/catalog`) are mounted under the base path the same way, the public-surface allow-list matches paths relative to it (the two `/.well-known` endpoints against the full path), and the Solid-OIDC `client_id` is the requested URI rebuilt from the base URL as written — so it equals, byte for byte, the actor URI the backend mints and the `${backendUrl}/as/actor/app` the front end derives. At the root (`http://localhost:3003`, local dev) every decision is identical to the pre-base-path code. To verify: `bun test backend/services` (with and without `SEMAPPS_HOME_URL=https://<host>/api/`).
**First-boot traps.**
- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment; changing them later means deleting the two datasets and redeploying.
- On a cold first boot, `GET <base>app` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `<base>as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment.
**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too.
## Backend image
`node:22-slim`, `pnpm install --frozen-lockfile --prod`, `pnpm start` (tsx over the Moleculer runner). Two things it needs that a local install hides:
- **pnpm 10 skips dependency build scripts unless approved**: `backend/package.json` approves `bcrypt`, `sharp` and `sodium-native` (`pnpm.onlyBuiltDependencies`). Without it the image builds and the container exits at start (`sharp-linux-x64.node` missing; bcrypt would fail next). A local `node_modules` may carry the binaries from an earlier manual build, which is how this stayed invisible.
- **sharp must not depend on GitHub at build time**: its installer downloads prebuilt libvips and binaries from GitHub, and that download timed out on every in-build attempt (2026-10-19) and failed the build. The image installs `libvips-dev` (so sharp detects a system libvips and compiles itself) and sets `npm_config_nodedir=/usr/local` (node-gyp uses the image's own headers). bcrypt still tries GitHub first and falls back to compiling, slower but working offline. Cost: a larger image (a multi-stage build keeping only `libvips42` at runtime would drop several hundred MB — not done).
## App image (root Dockerfile)
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
@@ -31,9 +49,7 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm
**`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated.
## CI/CD
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
## The NextGraph-era deployment
**A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA).
+10 -1
View File
@@ -1,17 +1,26 @@
FROM node:22-slim
# Install pnpm and build tools required for native modules (sharp, bcrypt).
# libvips-dev: with a system libvips present, sharp compiles itself against it instead of
# downloading its prebuilt binaries from GitHub, a download that timed out on every in-build
# attempt (2026-10-19) and failed the build.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
make \
g++ \
pkg-config \
libvips-dev \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
# node-gyp compiles against the headers this image already ships rather than downloading them.
ENV npm_config_nodedir=/usr/local
WORKDIR /app
# Install dependencies first to leverage Docker layer caching.
# Install dependencies first to leverage Docker layer caching. The native modules' build
# scripts only run because package.json approves them (pnpm.onlyBuiltDependencies).
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
+7
View File
@@ -34,5 +34,12 @@
"engines": {
"node": ">=22.12"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"sharp",
"sodium-native"
]
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+5 -4
View File
@@ -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
+35
View File
@@ -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://<host>/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 };
+55
View File
@@ -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'));
}
});
});
+5 -3
View File
@@ -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
+9 -5
View File
@@ -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'
},
+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 };
+70 -4
View File
@@ -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);
+96
View File
@@ -0,0 +1,96 @@
# Docker Compose definition used by Coolify to build and run Festipod straight from the
# git repository: one stack for the branch it follows (the test environment). The images
# are built on the Coolify server from the two Dockerfiles of this repository (the app at
# the root, the backend in /backend); nothing is pulled from a registry.
#
# Before `docker compose build` and `up`, Coolify writes a .env file next to this file
# with the variables defined in its UI plus generated ones. The generated ones used here:
# SERVICE_FQDN_BACKEND / SERVICE_FQDN_FRONTEND host of each service, as set in its
# Domains field
# SERVICE_NAME_BACKEND / SERVICE_NAME_FESTIPOD_REDIS service names (Coolify suffixes
# them with "-pr-<n>" in a preview)
# A value starting with `${` becomes an editable variable in Coolify (with its default);
# the other `${...}` references are only interpolated by docker compose.
#
# One base URL per environment, as for the other ActivityPods apps on this Coolify (it is
# what lets a pull request preview live on a single host): the frontend's domain serves the
# app, and the backend is served on the same host under `/api` and `/.well-known` — its
# Domains field is `https://<host>/api,https://<host>/.well-known`. The proxy must pass those
# paths through unchanged — Coolify's "Strip Prefixes" OFF, as on the other apps: the backend
# mounts its routes under `/api` itself, from its base URL, and keeps its `/.well-known` ones
# at the origin root (backend/services/base-path.js). With the prefixes stripped, every
# backend request would be refused (404).
#
# The backend registers its app actor in its main dataset on the first boot, with the
# domain it had at that time: set the domains in Coolify BEFORE the first deployment. If
# they change later, delete the two datasets in Fuseki and redeploy.
#
# Fuseki is the one shared with the other apps (shared-infra, in the "infra" project): the
# backend joins the "coolify" network to reach it, with its own pair of datasets. Redis
# runs inside the stack (on the stack's private network only, under a name that cannot be
# confused with the shared one).
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: always
depends_on:
- festipod-redis
expose:
- '3003'
# Coolify adds the stack's own network to every service; only the backend also needs
# the shared one, where Fuseki lives.
networks:
- coolify
environment:
SEMAPPS_APP_NAME: ${APP_NAME:-Festipod}
SEMAPPS_APP_DESCRIPTION: ${APP_DESCRIPTION:-Points de rencontre sur les événements publics}
SEMAPPS_APP_LANG: ${APP_LANG:-en,fr}
SEMAPPS_HOME_URL: https://${SERVICE_FQDN_BACKEND}/api/
SEMAPPS_FRONT_URL: https://${SERVICE_FQDN_FRONTEND}
SEMAPPS_PORT: '3003'
SEMAPPS_SHAPE_REPOSITORY_URL: ${SHAPE_REPOSITORY_URL:-https://shapes.activitypods.org/}
SEMAPPS_SPARQL_ENDPOINT: ${SPARQL_ENDPOINT}
SEMAPPS_JENA_USER: ${JENA_USER:-admin}
SEMAPPS_JENA_PASSWORD: ${JENA_PASSWORD}
# A pair of datasets named after DATASET_PREFIX and the backend's service name, e.g.
# festipod-backend / settings-festipod-backend (…-backend-pr-<n> in a preview). Two
# non-preview stacks on the same Fuseki would share them: give the second its own
# DATASET_PREFIX (or MAIN_DATASET / SETTINGS_DATASET).
SEMAPPS_MAIN_DATASET: ${MAIN_DATASET:-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME: ${SETTINGS_DATASET:-settings-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
SEMAPPS_REDIS_CACHE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/0
SEMAPPS_QUEUE_SERVICE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/1
frontend:
build:
context: .
dockerfile: Dockerfile
restart: always
depends_on:
- backend
expose:
- '3000'
# Read by the server on every request to /festipod-config.json, not inlined at build
# time: changing one needs a restart, not a rebuild.
environment:
PORT: '3000'
VITE_BACKEND_URL: https://${SERVICE_FQDN_BACKEND}/api
# The Pod provider the login screen offers in one click; any other can be typed in.
VITE_POD_PROVIDER_URL: ${POD_PROVIDER_URL:-https://mypod.store}
festipod-redis:
image: redis:7-alpine
restart: always
command: ['redis-server', '--appendonly', 'yes']
volumes:
- redis-data:/data
volumes:
redis-data:
networks:
coolify:
external: true
+6 -3
View File
@@ -774,7 +774,10 @@ async function tracked(): Promise<void> {
*/
async function publicSurface(): Promise<void> {
const G = 'backend public surface (anonymous GET, status codes only)';
const status = async (path: string): Promise<number> => (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<number> => (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<void> {
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',