908f7ea973
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.
181 lines
8.3 KiB
JavaScript
181 lines
8.3 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 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. The heal itself can ALSO lose the race: it may run before the
|
|
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
|
|
// heal found no group and silently did nothing), so it polls for the group instead of
|
|
// checking once. Idempotent: only patches when the link is missing.
|
|
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
|
|
const actorHasLink = async () => {
|
|
const actor = await this.broker.call('ldp.resource.get', {
|
|
resourceUri: actorUri,
|
|
accept: 'application/ld+json',
|
|
webId: 'system'
|
|
});
|
|
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 (await actorHasLink()) 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));
|
|
}
|
|
}
|
|
};
|