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.