Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12

This commit is contained in:
Sylvain Duchesne
2026-10-12 13:12:38 +02:00
parent 18fc71ff36
commit a61c8704c9
13 changed files with 882 additions and 827 deletions
@@ -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.
@@ -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.
@@ -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).