Compare commits
3 Commits
812844e99d
...
a28a318c24
| Author | SHA1 | Date | |
|---|---|---|---|
| a28a318c24 | |||
| 738d59596a | |||
| 8d0670a637 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -34,5 +34,12 @@
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bcrypt",
|
||||
"sharp",
|
||||
"sodium-native"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user