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 = /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}`); } } };