Files
festipod/backend/services/app.service.js
Sylvain Duchesne 223059c629 Run the pod provider from images with make start, and fix what a fresh stack exposed
- docker-compose-dev.yml: the redis host port is overridable (REDIS_PORT in the root
  .env, 6379 by default as in the sibling apps), and the pod provider, in host network
  mode, follows it for its queue and OIDC redis. All of ./data/fuseki is mounted at the
  image's SEMAPPS_FUSEKI_BASE, so account deletion removes the backups and, 24 h later,
  the secure dataset's files (it answered 500 without the mount).
- backend: GET /app served the app actor snapshot the mixin takes before it creates or
  replaces the access-need group, so a dataset's first boot lacked the link until a
  restart; the app service refreshes the snapshot from the store at the end of started().
- e2e smoke: the post-install poll sends Accept: application/ld+json; the pod answers 500
  to a container read in any other format.
- Docs: cookbook (Redis port, mounts, hot reload, dataset-named accounts, fresh-boot
  note), deployment note, env examples; claims about a parallel race are no longer stated
  as fact.
2026-11-02 21:46:37 +01:00

189 lines
8.6 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'
},
{
// The images users upload (event covers, avatars): the Pod's `file` container.
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/File'),
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: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 the mixin's
// snapshot of the actor, refreshed by this service's own started() (see there).
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');
// Moleculer registers this action only once every started() handler of the service has
// finished, by which time the mixin has set `this.appActor`: reading the actor from the
// triplestore when it is unset is a guard, not a boot path.
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. On a fresh dataset (2026-10-26)
// consents registered zero grants and a one-shot version of this heal found no group; the
// cause was not established — the mixin's started() creates the actor, then the group,
// before this handler runs. 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 — so the link
// is checked in the store and attached when missing, polling for the group rather than
// checking once. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const readActor = () =>
this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const linksGroup = actor => {
const link = actor && actor['interop:hasAccessNeedGroup'];
return Array.isArray(link) ? link.length > 0 : Boolean(link);
};
const findGroup = async () => {
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'
});
return groups && groups['ldp:contains'] && groups['ldp:contains'][0];
};
// A started() handler must not block the broker's boot, but the link must
// exist before the first consent/register flow; ~1 min is far past the
// observed gap (seconds) while the dataset is empty.
const deadline = Date.now() + 60000;
for (;;) {
try {
if (linksGroup(await readActor())) break;
const group = await findGroup();
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']}`);
break;
}
} catch (e) {
this.logger.warn(`Access-need-group heal attempt failed: ${e.message}`);
}
if (Date.now() >= deadline) {
this.logger.error('No access-need group appeared within 60s; the actor stays unlinked — consents will register zero grants');
break;
}
await new Promise(r => setTimeout(r, 2000));
}
// `get` serves `this.appActor`, the snapshot the mixin's started() takes BEFORE it creates
// or replaces the group: on a dataset's first boot that snapshot lacks the link, and on a
// boot that changes the access needs it names the group just deleted. `GET /app` kept
// that until the next restart (observed 2026-11-02), so refresh it from the store.
try {
const actor = await readActor();
if (linksGroup(actor)) this.appActor = actor;
} catch (e) {
this.logger.warn(`Could not refresh the app actor snapshot: ${e.message}`);
}
}
};