diff --git a/.env.example b/.env.example index 51d9955..5594d79 100644 --- a/.env.example +++ b/.env.example @@ -80,7 +80,10 @@ SEMAPPS_PORT=3003 # URL du backend telle que vue par le navigateur. VITE_BACKEND_URL=http://localhost:3003 -# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/app`). +# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/as/actor/app`). +# Le client_id DOIT être l'URI de l'acteur app : le pod enregistre l'app sous cette URI et +# ne sert l'en-tête `Link: rel="interop:registeredAgent"` (découverte de l'enregistrement +# par le auth provider) que si le client_id égalise l'acteur. VITE_BACKEND_CLIENT_ID= # Pod provider ActivityPods par défaut (optionnel). Vide → l'écran de login demande une URL. diff --git a/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md index 4da4e7d..f4cb5f7 100644 --- a/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md +++ b/.project/concepts/data-layer/activitypods-providers/contract_activitypods-providers.md @@ -86,3 +86,15 @@ Observed at the pinned commit. ## Change policy None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here. + +## Re-baseline 2026-10-12 (authorized one-time re-read of the installed 0.2.0) + +The user authorized a single re-read of the installed package to resolve the login-loop investigation; these paragraphs pin what the first reconstruction had not observed. They stand until the next re-baseline. + +**`registerApp` discovers the registration through a Link header, and the discovery key is the requester's identity.** `registerApp(webId)` fetches the user's WebID document, reads `interop:hasAuthorizationAgent`, then fetches the auth-agent document and parses its `Link` response header for `rel="http://www.w3.org/ns/solid/interop#registeredAgent"`; the first link's anchor is the AppRegistration URI. The pod provider only emits that header when the requesting app's identity — the access token's `azp`, i.e. the Solid-OIDC `client_id` — equals the registration's `registeredAgent`. The pod stores `registeredAgent` under the app actor URI (its authorize flow resolves the client_id document to the actor and passes the actor as `appUri`). CONSEQUENCE, verified end-to-end 2026-10-12: the `client_id` configured in `authProvider({clientId})` must be the app actor URI — not a separate document URI — or the lookup never matches and `registerApp` re-redirects to the consent screen forever. + +**`registerApp`'s fallback is a browser redirect, not an error.** When no registration is found (or the granted needs are stale), it stores the current path in `localStorage` under a return-to key, then sets `window.location` to the auth-agent's `interop:hasAuthorizationRedirectEndpoint` with a `client_id` query parameter, and resolves `undefined`. The app resumes at the stored path after the consent dance; that path is what the app's callback stage must parse. + +**`getAppStatus()` is a plain HTTP GET, and its shape is small.** It calls `GET /.well-known/app-status` on the pod provider's origin with the session token and returns the JSON body; the member the provider itself acts on is `upgradeNeeded` (boolean). A status probe that throws is treated by the provider's own upgrade check as fail-open (no upgrade). This resolves the previously documented gap on the `AppStatus` shape: `{ upgradeNeeded: boolean }` plus whatever the pod adds. + +**The built-in status drive exists and is on by default.** Unless `appStatusCheckInterval: false` is passed, the auth provider installs a periodic + `visibilitychange` check that silently re-runs `registerApp` (→ consent redirect) when `upgradeNeeded` turns true. Festipod currently opts out (`appStatusCheckInterval: false`, friction 1 in the usage declaration) and drives the check on its own schedule instead. diff --git a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md index 7226060..a85d21e 100644 --- a/.project/concepts/data-layer/activitypods-providers/usage_festipod.md +++ b/.project/concepts/data-layer/activitypods-providers/usage_festipod.md @@ -55,7 +55,7 @@ What must keep holding for this backend to serve the app. ## Frictions -1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. +1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. The re-baseline of 2026-10-12 pinned the `getAppStatus` shape (`GET /.well-known/app-status` → `{ upgradeNeeded }`), so the workaround is now implementable; wiring it is tracked in the handoff. 2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience. 3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible. 4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins. diff --git a/.project/handoff/refine-provider-abstraction.md b/.project/handoff/refine-provider-abstraction.md index 4548cb3..98cf0e2 100644 --- a/.project/handoff/refine-provider-abstraction.md +++ b/.project/handoff/refine-provider-abstraction.md @@ -123,7 +123,7 @@ The round: three adversarial passes (auth & security, data seam & backend, gates - `acl:Append` behaviour, as above. - The router's prefix stripping, with the second backend. - The four provider contributions. -- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The first half of the app-install blocker is CLEARED 2026-10-12 (tip `3aa9131`): the pod provider's OIDC adapter fetches the client_id URI and requires a Solid-OIDC Client ID Document (`@context` with `solid/oidc-context.jsonld`, `client_id` equal to the URI, plain `redirect_uris`); `GET /app` now serves exactly that when the request carries the `JsonLdContext` header, and keeps serving the app actor otherwise (the SAI install flow fetches the same URL without it). What remains on follow-up #1: a second, deeper drift surfaced on the first real consent (tip `6b48b24`) — `@activitypods/app` 2.3.2's `app-registrations.verify` expects `interop:satisfiesAccessNeed` on the AccessGrant, while the local pod provider HEAD writes it on the DataGrants (`hasDataGrant`), so every AppRegistration notification was rejected with "One or more required access needs have not been granted" and the consent looped; a backend middleware shim (`backend/services/apods-verify-compat.service.js`) reimplements verify resolving through DataGrants, the drift itself is platform-side and reported. After a user-side reinstall (the pod notifies once per consent and the inbox rejects unsigned replays 401), the loop should close and the join→collection `@suspendu` scenarios become un-suspendable. +- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned) and the join→collection `@suspendu` scenarios are unblocked but not yet re-enabled. - Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path. - Driving `getAppStatus()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens. - The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision). diff --git a/backend/services/apods-verify-compat.service.js b/backend/services/apods-verify-compat.service.js deleted file mode 100644 index 9d60342..0000000 --- a/backend/services/apods-verify-compat.service.js +++ /dev/null @@ -1,106 +0,0 @@ -const { MIME_TYPES } = require('@semapps/mime-types'); - -// Version-skew compatibility shim for the app registration handshake. -// -// `@activitypods/app` 2.3.2 (app side) and the local pod provider (HEAD) disagree on where -// an AccessGrant records the access needs it satisfies. The app-side `app-registrations.verify` -// tests `accessGrant['interop:satisfiesAccessNeed']` directly on the AccessGrant — the umbrella -// grant the pod lists in `interop:hasAccessGrant`. The pod provider now writes -// `interop:satisfiesAccessNeed` on the DataGrants instead, reached through -// AccessGrant -> `interop:hasDataGrant` -> DataGrant. The original test therefore never matches, -// verify returns `accessNeedsSatisfied: false`, the inbox processor throws -// "One or more required access needs have not been granted", the local AppRegistration is never -// stored, and the consent flow loops back to `/login?register_app=true`. -// -// This shim reimplements the original `verify` verbatim, with the access-need test resolving -// through DataGrants (falling back to the grant itself when it carries no DataGrant, so it -// tolerates both the old and the new pod-side shape). It is installed as a broker middleware -// because the target action lives inside the `@activitypods/app` package: the service itself is -// registered by that package, so the handler cannot be overridden by a normal mixin. The -// middleware is added to `broker.middlewares` in `created()`, before the services that expose -// `app-registrations.verify` are constructed, so it wraps every local action; only -// `app-registrations.verify` is intercepted. -// -// This is a workaround for platform drift, not a Festipod concern: the proper fix belongs -// upstream in `@activitypods/app`, and is reported to the provider rather than worked around there. - -const arrayOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]); - -module.exports = { - name: 'apods-verify-compat', - created() { - this.broker.middlewares.add({ - name: 'ApodsVerifyCompat', - localAction(next, action) { - if (action.name !== 'app-registrations.verify') return next; - - return async ctx => { - const { appRegistrationUri } = ctx.params; - - // Use local context to get all data - const jsonContext = await ctx.call('jsonld.context.get'); - - // Get from remote server, not local cache, to get latest version - const appRegistration = await ctx.call('ldp.remote.getNetwork', { - resourceUri: appRegistrationUri, - jsonContext - }); - - const accessGrants = await Promise.all( - arrayOf(appRegistration['interop:hasAccessGrant']).map(grantUri => - ctx.call('ldp.remote.get', { - resourceUri: grantUri, - jsonContext, - accept: MIME_TYPES.JSON - }) - ) - ); - - // Resolve each grant's DataGrants, where the pod provider now records the - // satisfied access needs (AccessGrant -> interop:hasDataGrant -> DataGrant). - const grantsWithDataGrants = await Promise.all( - accessGrants.map(async grant => ({ - grant, - dataGrants: await Promise.all( - arrayOf(grant['interop:hasDataGrant']).map(dataGrantUri => - ctx.call('ldp.remote.get', { - resourceUri: dataGrantUri, - jsonContext, - accept: MIME_TYPES.JSON - }) - ) - ) - })) - ); - - const satisfiesAccessNeed = accessNeedUri => - grantsWithDataGrants.some( - ({ grant, dataGrants }) => - dataGrants.some(dataGrant => dataGrant['interop:satisfiesAccessNeed'] === accessNeedUri) || - grant['interop:satisfiesAccessNeed'] === accessNeedUri - ); - - // Get required access need group(s) - const filteredContainer = await ctx.call('access-needs-groups.list', { - filters: { - 'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired' - }, - accept: MIME_TYPES.JSON - }); - const requiredAccessNeedGroups = arrayOf(filteredContainer['ldp:contains']); - - // Return true if all access needs and special rights of the required AccessNeedGroup(s) are granted - const accessNeedsSatisfied = requiredAccessNeedGroups.every( - group => - arrayOf(group['interop:hasAccessNeed']).every(accessNeedUri => satisfiesAccessNeed(accessNeedUri)) && - arrayOf(group['interop:hasSpecialRights']).every(specialRightUri => - appRegistration['apods:hasSpecialRights'].some(sr => sr === specialRightUri) - ) - ); - - return { accessNeedsSatisfied, appRegistration, accessGrants }; - }; - } - }); - } -}; diff --git a/backend/services/app.service.js b/backend/services/app.service.js index 655a11b..0919779 100644 --- a/backend/services/app.service.js +++ b/backend/services/app.service.js @@ -68,7 +68,7 @@ module.exports = { // 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: { - handler(ctx) { + 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. @@ -78,17 +78,73 @@ module.exports = { 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 + // survives in `originalUrl`. + const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0]; return { '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], - client_id: urlJoin(CONFIG.HOME_URL, 'app'), + client_id: urlJoin(CONFIG.HOME_URL, requestPath), redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')], client_name: CONFIG.APP_NAME, client_uri: CONFIG.FRONT_URL }; } - return this.appActor; + 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']}`); } } } diff --git a/backend/services/client-id-doc.service.js b/backend/services/client-id-doc.service.js new file mode 100644 index 0000000..075cff0 --- /dev/null +++ b/backend/services/client-id-doc.service.js @@ -0,0 +1,50 @@ +const urlJoin = require('url-join'); +const CONFIG = require('../config/config'); + +// Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual +// personality, discriminated by the request's `JsonLdContext` header — same contract as +// the `/app` route (see app.service.js for the full story), but implemented as a +// standalone service with no dependency on the `app` service. +// +// WHY A SEPARATE SERVICE: the Solid-OIDC client_id IS the actor uri (the pod provider's +// auth-agent only returns the `Link: rel="interop:registeredAgent"` header when the +// requesting app's identity — the token's azp, i.e. this client_id — matches the +// registration's `registeredAgent`, which the pod stores under the actor uri). The +// adapter therefore fetches THIS path as the client metadata document. Routing the path +// to `app.get` deadlocked boot: `@activitypods/app`'s `awaitCreateComplete` polls the +// actor over HTTP while the `app` service is still starting, and an unregistered action +// 503s the poll it waits on. This service has no `started()` work and no dependencies, +// so it is routable long before the `app` service exists. +module.exports = { + name: 'client-id-doc', + actions: { + get: { + async handler(ctx) { + 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'); + + if (isOidcClientIdDocument) { + // The client_id must equal the fetched URI exactly. `req.url` is rewritten to + // the alias remainder by moleculer-web; the raw path survives in `originalUrl`. + const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0]; + return { + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + client_id: urlJoin(CONFIG.HOME_URL, requestPath), + redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')], + client_name: CONFIG.APP_NAME, + client_uri: CONFIG.FRONT_URL + }; + } + + return await ctx.call('ldp.resource.get', { + resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'), + accept: 'application/ld+json', + webId: 'system' + }); + } + } + } +}; diff --git a/backend/services/core/core.service.js b/backend/services/core/core.service.js index 86630aa..99c7aca 100644 --- a/backend/services/core/core.service.js +++ b/backend/services/core/core.service.js @@ -39,6 +39,24 @@ module.exports = { json: true } }, + { + path: '/as/actor/app', + aliases: { + 'GET /': { + // Served by a standalone service (not `app.get`): the Solid-OIDC client_id + // IS the actor uri, and routing this to `app.get` deadlocked boot (the app + // service's own startup polls this path over HTTP). See client-id-doc.service.js. + action: 'client-id-doc.get', + passReqResToParams: true + } + }, + mappingPolicy: 'restrict', + authentication: false, + authorization: false, + bodyParsers: { + json: true + } + }, { path: '/catalog', aliases: { diff --git a/reports/cucumber-report.html b/reports/cucumber-report.html index c01d078..53013da 100644 --- a/reports/cucumber-report.html +++ b/reports/cucumber-report.html @@ -50,7 +50,7 @@