Files
festipod/backend/services/app.service.js
T
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

153 lines
7.0 KiB
JavaScript

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.
//
// 1. Solid-OIDC Client ID Document. The pod provider's OIDC adapter (RedisAdapter.find)
// resolves an unknown `client_id` that starts with `http` by fetching it, sending
// `Accept: application/ld+json` and `JsonLdContext: https://www.w3.org/ns/solid/oidc-context.jsonld`.
// The pod provider runs the login with `client_id = <HOME_URL>/app`, so this same URL must,
// for that fetch, return a document whose `@context` literally includes the Solid-OIDC
// context, whose `client_id` equals the fetched URI exactly, and which carries `redirect_uris`.
// Anything else fails `invalid_client_metadata / client_id is mandatory property`.
//
// 2. SAI app description (the actor). The pod provider's auth-agent fetches this URL
// WITHOUT the `JsonLdContext` header (`ldp.remote.getNetwork`) to install/describe the app,
// and expects the app actor document served today (`type: [Application, interop:Application]`).
//
// The `JsonLdContext` header is the only discriminator: same path, two representations.
module.exports = {
mixins: [AppService],
settings: {
baseUrl: CONFIG.HOME_URL,
app: {
name: CONFIG.APP_NAME,
description: CONFIG.APP_DESCRIPTION,
thumbnail: urlJoin(CONFIG.FRONT_URL, 'logo.svg'),
frontUrl: CONFIG.FRONT_URL,
supportedLocales: CONFIG.APP_LANG
},
oidc: {
clientUri: CONFIG.FRONT_URL,
redirectUris: urlJoin(CONFIG.FRONT_URL, 'login'),
postLogoutRedirectUris: urlJoin(CONFIG.FRONT_URL, 'login?logout=true'),
tosUri: null
},
accessNeeds: {
required: [
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
accessMode: ['acl:Read', 'acl:Write', 'acl:Control'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
accessMode: 'acl:Read',
preferredScope: 'interop:AllFromRegistry'
},
'apods:ReadInbox',
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:QuerySparqlEndpoint',
'apods:CreateWacGroup',
'apods:CreateCollection',
'apods:UpdateWebId'
],
optional: []
},
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
},
actions: {
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
// mixin's `started()`, i.e. long before any request is served.
get: {
async handler(ctx) {
// moleculer-web exposes the raw request only when the route alias sets
// passReqResToParams (see services/core/core.service.js). Internal calls
// (ctx.call('app.get')) have no `$req`, so they always get the actor.
const req = ctx.params && ctx.params.$req;
const headers = (req && req.headers) || {};
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
// `this.appActor` is set at the very END of the mixin's started(). During boot,
// this action is already routable (the api gateway is up before the services
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
// actor over HTTP through this very route — waiting on an action that waits on the
// boot it blocks. Read the actor straight from the triplestore in that window.
let actor = this.appActor;
if (!actor) {
try {
actor = await ctx.call('ldp.resource.get', {
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
accept: 'application/ld+json',
webId: 'system'
});
} catch (e) {
actor = undefined;
}
}
if (isOidcClientIdDocument) {
// 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, 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: 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
};
}
return actor;
}
}
},
async started() {
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
// actor in an after.post hook of the group's creation, but the hook races the actor's own
// creation on a cold start (both run inside the broker's parallel started() phase): the
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
// needs from that link — an empty consent registers the app with zero grants and the
// register flow loops. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const actor = await this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const link = actor && actor['interop:hasAccessNeedGroup'];
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
if (!hasLink) {
const groups = await this.broker.call('access-needs-groups.list', {
filters: {
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
},
accept: 'application/ld+json'
});
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
accessNeedGroupUri: group.id || group['@id']
});
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
}
}
}
};