Files
Sylvain Duchesne 908f7ea973 Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work
Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
2026-10-30 17:06:38 +01:00

100 lines
5.9 KiB
JavaScript

// The backend's whole HTTP surface, as an allow-list checked in front of every route.
//
// WHY. CoreService mounts routes Festipod never asked for — above all the LDP catch-all
// (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`), over the dataset that
// holds the app actor's PRIVATE key (`/key`), the user registrations and grants the app holds
// (`/interop/…`), the catalog store and the ledgers. WebACL filters what those routes serve (the main
// dataset is a secure one, see core.service.js), so each resource is only as private as the rights
// its container gave it; this gate does not depend on those rights being right. A signed request is
// no better than an anonymous one here: any pod user can sign one. Rather than chase each route
// (deny-list), the gateway serves ONLY what a caller legitimately needs and refuses everything else
// with a 404 — for every caller, anonymous or signed, since none needs more over HTTP: the backend's
// own services reach its data through internal calls, never through these routes.
//
// WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs):
// GET /app the app description — the Festipod front end and the pod (install)
// GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod
// GET /catalog the public catalog — the Festipod front end
// GET /public-key/<id> the app actor's PUBLIC key — every pod verifying an app signature
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
// what the app asks for — the pod, at install and consent
// GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod
// GET /.well-known/nodeinfo, /nodeinfo/2.1
// server discovery (links the app actor) — the pod
// POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform)
// POST /.webhooks/<id> the pods' notifications to the app's watcher (see emitted-activity.js)
// HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces.
//
// WHERE. The paths above are relative to the backend's base path (`/` locally, `/api/` on Coolify —
// see base-path.js), except the two `/.well-known` ones, which SemApps mounts at the origin root
// whatever the base path. A path outside the base path is refused like any other.
//
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
// actor's other collections, and every write through the LDP routes.
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
const { MoleculerClientError } = require('moleculer').Errors;
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
// encoded separator or traversal; no empty segment.
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
const READ = ['GET', 'HEAD'];
const PUBLIC_ENDPOINTS = [
{ methods: READ, path: /^\/app$/ },
{ methods: READ, path: /^\/as\/actor\/app$/ },
{ methods: READ, path: /^\/catalog$/ },
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
];
/** The endpoints at the origin root, whatever the base path. */
const ROOT_ENDPOINTS = [
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ }
];
/**
* isPublicRequest(method, path, basePath = '/') → boolean. True only when `method` (upper case) on
* `path` (the request path without its query string, as the gateway routes it) is in `ROOT_ENDPOINTS`,
* or lies under `basePath` (as `basePathOf` returns it) and, relative to it, is in `PUBLIC_ENDPOINTS`.
*/
function isPublicRequest(method, path, basePath = '/') {
if (typeof method !== 'string' || typeof path !== 'string') return false;
const verb = method.toUpperCase();
const listed = (endpoints, p) => endpoints.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(p));
if (listed(ROOT_ENDPOINTS, path)) return true;
if (!path.startsWith(basePath)) return false;
return listed(PUBLIC_ENDPOINTS, path.slice(basePath.length - 1));
}
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
function routedPathOf(req) {
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
const raw = String(req.originalUrl || req.url || '').split('?')[0];
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/**
* createPublicSurfaceGate(basePath = '/') → publicSurfaceGate(req, res, next), a moleculer-web global
* middleware (`api.use`, which the gateway puts in front of every route, including the ones services
* add at runtime). Passes a public request (`isPublicRequest` under `basePath`) through; answers any
* other with 404 before authentication or any action runs.
*/
function createPublicSurfaceGate(basePath = '/') {
return function publicSurfaceGate(req, res, next) {
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
if (isPublicRequest(method, routedPathOf(req), basePath)) return next();
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
};
}
module.exports = { isPublicRequest, createPublicSurfaceGate, PUBLIC_ENDPOINTS, ROOT_ENDPOINTS };