diff --git a/backend/services/apods-verify-compat.service.js b/backend/services/apods-verify-compat.service.js new file mode 100644 index 0000000..9d60342 --- /dev/null +++ b/backend/services/apods-verify-compat.service.js @@ -0,0 +1,106 @@ +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 }; + }; + } + }); + } +};