From 73bfda4bec62a916edc48f44623ce73d190231d2 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 21:55:34 +0200 Subject: [PATCH 01/11] feat(auth): one identity state per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity used to be assembled per call site from session fields, a cookie fallback and a cleared mark, each with its own predicate; every new async path (a slow restore, a late probe, a refocus) could reintroduce the same class of bug in a different place. The state now lives once per session in identityState.ts: - sessionIsActive() is the single activity rule (an explicit false wins over a cached WebID); - every observation (session event, token update, refocus resync, cookie probe result) is applied to one record, so a change is detected and reported once, whichever path noticed it; - the record carries a version and the newest resync attempt, so an answer that belongs to an identity the session has since left — a restore started under Alice answering after Bob logged in — is dropped, and a caller that joins an attempt in flight is judged against the attempt's own baseline; - transitions are derived from the previous/next snapshots (classifyTransition/identityReplaced), not from flags, so an unchanged identity costs no event. The cookie fallback and the setTokenDetails watch are TEMPORARY: they exist because uvdsl announces only isActive changes — not a WebID change that keeps the session active, and not a cookie identity change at all. Both are isolated here so they become a small deletion when the library reports identity changes itself. Nothing consumes the state yet: the next commit moves authSession, SolidAuthnLogic and the fetch bridge onto it. Tests: test/identityState.test.ts (15) — activity/predicate table, login and logout (one replacement, no second one after the WebID is cleared), A->B while active, an A->B that uvdsl never announces around setTokenDetails, cross-tab clear reported once, joined resync, dropped stale answer, cookie identity adopt/replace/clear, release semantics, refocus fan-out. --- src/authSession/identityState.ts | 584 +++++++++++++++++++++++++++++++ test/identityState.test.ts | 319 +++++++++++++++++ 2 files changed, 903 insertions(+) create mode 100644 src/authSession/identityState.ts create mode 100644 test/identityState.test.ts diff --git a/src/authSession/identityState.ts b/src/authSession/identityState.ts new file mode 100644 index 0000000..e780512 --- /dev/null +++ b/src/authSession/identityState.ts @@ -0,0 +1,584 @@ +/** + * One identity state per session. + * + * The session's identity is assembled from two sources — the OIDC session + * itself and (on NSS localhost setups) a cookie-backed fallback — and is + * observed by consumers that must invalidate what the previous identity + * fetched. Doing that with independent flags and predicates per call site let + * each new async path (a slow restore, a probe answering late, a refocus) + * reintroduce the same class of bug, so the identity lives here, once: + * + * TEMPORARY, pending the uvdsl change: two mechanisms here exist only because + * `@uvdsl/solid-oidc-client-browser` announces `sessionStateChange` when + * `isActive` changes and nothing else — it does not report a WebID change that + * keeps the session active, and it does not push a login/logout made in + * another tab. Until it does (issue drafted), the cookie fallback is the only + * way to see an NSS cookie identity change at all (see + * `reportCookieIdentity`) and `watchTokenUpdates()` is the only way to see an + * A -> B switch. Both are isolated here so they become a small deletion, not + * a hunt, when uvdsl reports identity changes itself. + * + * - `sessionIsActive()` is the single activity rule (`isActive` is + * authoritative, an explicit `false` wins over a cached WebID); + * - every observation (session event, token update, refocus resync, cookie + * probe result) is applied to ONE record, so a change is detected and + * reported once, whichever path noticed it; + * - the record carries a `version` (bumped when a transition is applied) and + * the newest refresh attempt, so an answer that belongs to an identity the + * session has since left — a restore started under Alice answering after + * Bob logged in — is dropped instead of being applied; + * - the transition is derived from the previous/next snapshots + * (`classifyTransition`, `identityReplaced`), not from flags: there is + * nothing to keep in sync, and an unchanged identity costs no event. + * + * Reported events (same vocabulary as before): + * 'logout' — the session went from active to inactive; + * 'sessionChange' — any other change of active state or WebID; + * 'identityReplaced' — an identity that WAS actively established is gone or + * replaced, so data fetched under it must be dropped. + * Start-up (none -> A) and a token refresh for the same identity are not + * replacements. + * + * A session whose backing store no longer holds it (a cross-tab logout) is + * marked `cleared` here: the local session object keeps reporting the previous + * identity, so the mark is what makes the derived reads report logged out + * until the session reports an identity again. + * + * DO NOT keep identity state anywhere else: `SolidAuthnLogic` reads it, + * `authSession` publishes it (`info`, events) and `solidLogicSingleton` + * decides from it whose credentials a request would carry. + */ + +export type SessionLike = { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + addEventListener?: (type: string, listener: () => void) => void + setTokenDetails?: (...args: unknown[]) => unknown + restore?: () => Promise +} + +export type IdentityEvent = 'logout' | 'sessionChange' | 'identityReplaced' + +/** The session's own view: what the raw session reports, cleared-aware. */ +export type IdentitySnapshot = { isActive: boolean, webId?: string } + +export type DocumentLike = { + visibilityState?: string + addEventListener?: (type: string, listener: () => void) => void + removeEventListener?: (type: string, listener: () => void) => void +} + +/** + * Which legacy event a session transition should emit: + * 'logout' — the session went from active to inactive; + * 'sessionChange' — any other change of active state or WebID (a login here + * is also announced as 'login' by SolidAuthnLogic; the + * duplicate is harmless — consumers only invalidate); + * null — nothing changed, so a refocused tab with the same + * identity costs no event and no invalidation. + */ +export function classifyTransition ( + prev: IdentitySnapshot, + next: IdentitySnapshot +): 'logout' | 'sessionChange' | null { + if (prev.isActive !== next.isActive) return next.isActive ? 'sessionChange' : 'logout' + return next.webId !== prev.webId ? 'sessionChange' : null +} + +/** + * Whether an established identity was replaced or cleared — a session that had + * a WebID no longer reports the same one (A -> B), or no longer reports being + * active (A -> logged out, A -> none). Data fetched under the previous + * identity cannot be re-validated document by document, so this is the signal + * to discard it. + * + * Only the transition OUT of an ACTIVELY established identity counts: once the + * session has gone inactive (webId possibly retained), the replacement was + * already reported — clearing the WebID afterwards or logging in as someone + * else is not a second replacement. + */ +export function identityReplaced (prev: IdentitySnapshot, next: IdentitySnapshot): boolean { + if (prev.webId === undefined) return false + if (!prev.isActive) return false + return next.webId !== prev.webId || !next.isActive +} + +/** + * Whether the session counts as active. `isActive` is authoritative — an + * explicit `false` wins even when a WebID is still cached (a logout that has + * not cleared it yet); the WebID only fills in an undefined state. + */ +export const sessionIsActive = (session: SessionLike): boolean => + session.isActive === true || (session.isActive === undefined && Boolean(session.webId)) + +/** + * How long a refocus resync may delay the snapshot comparison. + */ +const RESYNC_TIMEOUT_MS = 2000 + +type Subscriber = { + onEvent?: (event: IdentityEvent) => void + onRefocus?: () => void | Promise + resync?: () => unknown +} + +type Attempt = { + id: number + /** The record version the attempt started from. */ + version: number + /** The raw identity the attempt started from. */ + raw: { isActive: boolean, webId?: string } + promise: Promise +} + +type Record = { + session: SessionLike + /** The last applied view of the session, cleared-aware. */ + raw: IdentitySnapshot + /** The backing store no longer holds the session (a cross-tab logout). */ + cleared: boolean + /** The cookie-probed identity, when the session does not own one. */ + cookieWebId: string | null + /** Bumped whenever a session transition is applied. */ + version: number + /** The newest resync attempt, or undefined when none is running. */ + attempt?: Attempt + attemptId: number + subscribers: Set + /** Whether the session state listener is attached (once per session). */ + listenerAttached: boolean + /** Detaches the document listener when the last subscriber leaves. */ + detachDocument?: () => void + /** Wrapped once per session, see watchTokenUpdates(). */ + watchingTokenUpdates: boolean +} + +const records = new WeakMap() + +// One restore at a time per session: `restore()` can mutate the session before +// it resolves, so two overlapping restores could write an older identity back +// over a newer one. Every caller (the refocus resync and `checkUser()`) goes +// through this lock, and one that arrives while a restore is in flight joins +// it instead of starting another. +const restoresInFlight = new WeakMap>() + +const recordOf = (session: SessionLike): Record | undefined => { + if (typeof session !== 'object' || session === null) return undefined + let record = records.get(session as object) + if (!record) { + record = { + session, + raw: { isActive: false }, + cleared: false, + cookieWebId: null, + version: 0, + attemptId: 0, + subscribers: new Set(), + listenerAttached: false, + watchingTokenUpdates: false + } + record.raw = snapshotOf(record) + records.set(session as object, record) + } + return record +} + +/** + * Whether a session was reported cleared: its backing store lost the session + * while this tab still had one, so its identity no longer holds here. + */ +export function sessionWasCleared (session: unknown): boolean { + if (typeof session !== 'object' || session === null) return false + return records.get(session as object)?.cleared === true +} + +/** + * Whether the session explicitly reports itself inactive. An explicit `false` + * — `isActive` on the session or `isLoggedIn` on the legacy `info` shape — + * wins over a retained WebID: a partial logout that has not cleared the cached + * WebID must not keep identifying the previous user. Consumers that would + * otherwise act on the WebID alone (authenticated fetch, currentUser) use this + * to stand down. + */ +export function sessionExplicitlyInactive (session: SessionLike): boolean { + if (sessionWasCleared(session)) return true + return session?.isActive === false || session?.info?.isLoggedIn === false +} + +/** + * Whether the OIDC session currently owns the identity. A session that was + * reported cleared does not, and neither does one that explicitly reports + * itself logged out (a legacy shape that still carries a WebID would otherwise + * pass `sessionIsActive()` alone). + */ +export function sessionOwnsIdentity (session: SessionLike): boolean { + return !sessionExplicitlyInactive(session) && sessionIsActive(session) +} + +/** The WebID the raw session publishes, or undefined when it owns none. */ +export function sessionIdentityWebId (session: SessionLike): string | undefined { + return sessionOwnsIdentity(session) ? session.webId : undefined +} + +const snapshotOf = (record: Record): IdentitySnapshot => { + // A cleared session answers as logged out until it reports an identity + // again: the local session object may still carry the previous WebID. + if (record.cleared) return { isActive: false } + return { isActive: sessionIsActive(record.session), webId: record.session.webId } +} + +/** + * The identity a caller should act as: the session's own when it owns one, the + * cookie-probed one when the session is inactive (or cleared) and a probe + * established it, and undefined when neither does. This is the last word for + * `currentUser()`, `info` consumers and the fetch bridge. + */ +export function effectiveIdentity (session: SessionLike): { webId?: string, source: 'none' | 'session' | 'cookie' } { + const record = recordOf(session) + if (!record) return { source: 'none' } + if (sessionOwnsIdentity(session)) { + return session.webId === undefined ? { source: 'none' } : { webId: session.webId, source: 'session' } + } + if (record.cookieWebId !== null) return { webId: record.cookieWebId, source: 'cookie' } + // A session that only reports a logout keeps no identity — the retained + // WebID must not be used (see sessionExplicitlyInactive). + return { source: 'none' } +} + +/** + * The legacy `info` shape is session-derived and stays derived: callers + * snapshot and restore it, so a retained value must never answer for the + * session. `isLoggedIn` follows `sessionIsActive` — an explicit `isActive: + * false` reports logged out even when a WebID is still cached, or the fetch + * bridge would keep routing anonymous requests through the authenticated + * fetch. + */ +export function legacySessionInfo (session: SessionLike): { webId?: string, isLoggedIn?: boolean } { + if (sessionWasCleared(session)) return { webId: undefined, isLoggedIn: false } + return { webId: session.webId, isLoggedIn: sessionIsActive(session) } +} + +/** + * Runs `session.restore()`, sharing an attempt that is already in flight. + * + * @returns the shared promise, or undefined when the session has no restore. + */ +export function restoreSession (session: SessionLike | undefined): Promise | undefined { + const restore = session?.restore + if (typeof restore !== 'function' || typeof session !== 'object' || session === null) { + return undefined + } + const key = session as object + const inFlight = restoresInFlight.get(key) + if (inFlight) return inFlight + const started = Promise.resolve() + .then(() => restore.call(session)) + .finally(() => { restoresInFlight.delete(key) }) + restoresInFlight.set(key, started) + return started +} + +const emit = (record: Record, event: IdentityEvent): void => { + record.subscribers.forEach(subscriber => subscriber.onEvent?.(event)) +} + +/** + * Re-reads the session and reports what changed since the last observation. + * The cleared mark is dropped first when the session reports an identity + * again, otherwise it would mask the new identity forever. + */ +function note (record: Record): void { + if (record.cleared && sessionIsActive(record.session)) { + record.cleared = false + } + // The session owns the identity again: a cookie identity remembered from + // before it activated is stale — the probe only runs while the session is + // inactive, and after a session logout the remembered cookie value must not + // answer as if it had just been probed. Silent on purpose: the session + // transition is what is reported, not this book-keeping. + if (sessionOwnsIdentity(record.session)) record.cookieWebId = null + const next = snapshotOf(record) + const event = classifyTransition(record.raw, next) + const replaced = identityReplaced(record.raw, next) + const moved = event !== null || replaced + record.raw = next + if (!moved) return + // The session moved on: an attempt that started before this transition is + // answering about a state that no longer holds. + record.version += 1 + if (event) emit(record, event) + if (replaced) emit(record, 'identityReplaced') +} + +/** + * The backing store has no session while this tab still believes it is signed + * in: report the logout and the replacement for the identity that was active, + * once — a later refocus that still finds no session must not repeat it. + */ +function reportCleared (record: Record): void { + if (record.cleared) return + const wasActive = record.raw.isActive + const wasEstablished = record.raw.webId !== undefined + // Only a session that was actually in use has to be invalidated: an + // anonymous tab is cleared already, and marking it would make snapshotOf() + // report the cleared state — masking a later login until some other + // observation happens to run. + if (!wasActive || !wasEstablished) { + record.raw = snapshotOf(record) + if (wasActive) { + record.version += 1 + emit(record, 'logout') + } + return + } + // The local session object still reports the old identity: the mark is what + // makes the derived reads stop answering for it, and the cleared snapshot is + // the baseline — a later activation is a new login, and a still-cleared + // session cannot report the logout twice. + record.cleared = true + record.raw = snapshotOf(record) + record.version += 1 + emit(record, 'logout') + emit(record, 'identityReplaced') +} + +/** + * Starts (or joins) the newest resync attempt. A caller that joins an attempt + * in flight must judge its answer against the identity the ATTEMPT started + * from, not the one it sees now — hence the baseline is stored with the + * attempt. + */ +function startAttempt (record: Record, action: () => unknown): Attempt { + if (record.attempt) return record.attempt + const attempt: Attempt = { + id: ++record.attemptId, + version: record.version, + raw: { isActive: sessionIsActive(record.session), webId: record.session.webId }, + promise: Promise.resolve() + .then(action) + .finally(() => { if (record.attempt === attempt) record.attempt = undefined }) + } + record.attempt = attempt + return attempt +} + +/** + * Re-reads the session through `resync` (when given) and reports the + * transition, bounded by RESYNC_TIMEOUT_MS so a hung restore cannot stall a + * refocus — but keeping the outcome: a restore that only finishes later can + * still report the session gone, and dropping it would leave this tab on the + * old identity until some other event. + */ +async function resyncThenNote (record: Record): Promise { + const resync = resyncActionOf(record) + if (typeof resync !== 'function') { + note(record) + return + } + const runResync = resync + const started = startAttempt(record, () => runResync()) + // This attempt's outcome only applies while it is still the newest one and no + // transition was applied since the attempt (not this caller) started. + const stale = (): boolean => started.id !== record.attemptId || record.version !== started.version + // A `'cleared'` answer only applies while the session still reports the + // identity the attempt started from: `restore()` rejects with "no session" + // for the identity it was started for, so if the session reports a different + // identity now, the answer is about the previous one and must not log the new + // one out. + const sameRawIdentity = (): boolean => + sessionIsActive(record.session) === started.raw.isActive && record.session.webId === started.raw.webId + let outcome: unknown + let done = false + const attempt = started.promise.then( + (value) => { outcome = value; done = true }, + () => { done = true } // compared as it stands + ) + const apply = (): void => { + if (stale()) return + if (outcome === 'cleared') { + if (sameRawIdentity()) reportCleared(record) + return + } + // Any other result may have updated the session (a cross-tab login): + // comparing again is what turns that into `sessionChange` / + // `identityReplaced` instead of leaving this tab on the old identity. + note(record) + } + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { timer = setTimeout(resolve, RESYNC_TIMEOUT_MS) }) + try { + await Promise.race([attempt, timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + if (done) { + apply() + return + } + note(record) + void attempt.then(apply) +} + +/** + * The resync action lives with the subscriber that knows how to run it + * (`authSession` maps a restore rejection to `'cleared'`); the module keeps + * the newest one so a refocus started from any subscriber re-reads the + * session. + */ +function resyncActionOf (record: Record): (() => unknown) | undefined { + for (const subscriber of record.subscribers) { + if (subscriber.resync) return subscriber.resync + } + return undefined +} + +// uvdsl's session announces only changes of `isActive`; a WebID can change +// while both states stay active and would go unseen. Every token update goes +// through `setTokenDetails`, so compare the identity around it. Wrapped once +// per session. +// +// TEMPORARY, pending the uvdsl change: delete this wrapper when the library +// dispatches `sessionStateChange` for an active -> active WebID change too +// (see the module header). The state model does not depend on it: any +// observation path reports the same transition. +function watchTokenUpdates (record: Record): void { + const session = record.session + const original = session.setTokenDetails + if (typeof original !== 'function' || record.watchingTokenUpdates) return + record.watchingTokenUpdates = true + session.setTokenDetails = (...args: unknown[]): unknown => { + const before = snapshotOf(record) + const changed = (): boolean => { + const after = snapshotOf(record) + return after.webId !== before.webId || after.isActive !== before.isActive + } + const result = original.apply(session, args) + if (result && typeof (result as Promise).then === 'function') { + return (result as Promise).then((value) => { + if (changed()) note(record) + return value + }) + } + if (changed()) note(record) + return result + } +} + +export type IdentitySubscription = { + /** + * Applies a cookie-probe result. Ignored once the subscription is released, + * so a probe that answers after its owner was replaced cannot resurrect an + * identity — and while the session owns the identity it must not be replaced + * by a cookie one at all. + */ + reportCookieIdentity: (webId: string | null) => void + unsubscribe: () => void +} + +/** + * Observes the session for identity transitions, and for refocuses (one + * document listener per session, removed when the last subscription is + * released, so a replaced logic instance adds no second listener). + * + * `onEvent` receives the derived events; `onRefocus` runs on a refocus (the + * cookie revalidation, in SolidAuthnLogic); `resync` re-reads the session + * before the comparison (authSession owns it, because it knows that a restore + * rejection means "no session"). + */ +export function subscribeIdentity ( + session: SessionLike, + options: Subscriber = {} +): IdentitySubscription { + const record = recordOf(session) + if (!record) { + return { reportCookieIdentity: () => undefined, unsubscribe: () => undefined } + } + const subscriber: Subscriber = { ...options } + record.subscribers.add(subscriber) + if (!record.listenerAttached) { + record.listenerAttached = true + if (typeof session.addEventListener === 'function') { + session.addEventListener('sessionStateChange', () => note(record)) + } + watchTokenUpdates(record) + } + const doc: DocumentLike | undefined = typeof document === 'undefined' ? undefined : document + if (record.subscribers.size === 1 && doc && typeof doc.addEventListener === 'function') { + const handler = (): void => { + if (doc.visibilityState !== 'visible') return + // Wake up the session first; the cookie identity is invisible to it and + // is revalidated in parallel by the subscriber that owns the probe. + void resyncThenNote(record) + record.subscribers.forEach(sub => { void sub.onRefocus?.() }) + } + doc.addEventListener('visibilitychange', handler) + record.detachDocument = () => doc.removeEventListener?.('visibilitychange', handler) + } + let released = false + return { + reportCookieIdentity: (webId: string | null): void => { + if (released) return + applyCookieIdentity(record, webId) + }, + unsubscribe: (): void => { + if (released) return + released = true + record.subscribers.delete(subscriber) + if (record.subscribers.size === 0) { + record.detachDocument?.() + record.detachDocument = undefined + } + } + } +} + +/** + * Applies a cookie-probe result to the record. Only cookie-backed changes are + * reported: an OIDC identity change is already emitted by `note()`, and + * reporting it again would duplicate the events — a reload consumer would + * reload twice. While the session owns the identity a cookie one must not + * replace it, and `sessionChange` is emitted only when it does not (an active + * session already reported its own transition). + */ +function applyCookieIdentity (record: Record, webId: string | null): void { + if (record.cookieWebId === webId) return + // While the session owns the identity a cookie one must not replace it — a + // probe only runs while the session is inactive for exactly that reason. + if (sessionOwnsIdentity(record.session)) return + const previousCookieBacked = record.cookieWebId !== null + record.cookieWebId = webId + emit(record, 'sessionChange') + // The replacement is owed whenever the identity being REPLACED was + // cookie-backed — the session watcher could not see it. Adopting one is not + // a replacement: there was nothing of a previous user to drop. + if (previousCookieBacked) emit(record, 'identityReplaced') +} + +/** + * Reload the page when the identity that was active in this tab is replaced or + * cleared — the pragmatic way to drop everything fetched under the previous + * identity (store, panes, editability), instead of repairing every read path. + * + * Consumer-side on purpose: navigation is an application decision (solid-ui, + * mashlib), and tests inject their own action. + */ +export function reloadOnIdentityReplaced ( + events: { on?: (event: 'identityReplaced', handler: () => void) => void } | undefined, + reload: () => void = () => { + if (typeof window !== 'undefined') window.location.reload() + } +): void { + if (!events || typeof events.on !== 'function') return + events.on('identityReplaced', reload) +} + +/** + * Re-reads the session and reports the transition, without a resync action + * (a plain observation, used by tests and by callers that already know the + * session was refreshed). + */ +export function observeSession (session: SessionLike): void { + const record = recordOf(session) + if (record) note(record) +} diff --git a/test/identityState.test.ts b/test/identityState.test.ts new file mode 100644 index 0000000..6389605 --- /dev/null +++ b/test/identityState.test.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + classifyTransition, + effectiveIdentity, + identityReplaced, + legacySessionInfo, + sessionExplicitlyInactive, + sessionIsActive, + sessionOwnsIdentity, + subscribeIdentity, + type IdentityEvent +} from '../src/authSession/identityState' + +type Listener = () => void + +/** + * A fake uvdsl-style session: `isActive`/`webId` properties, a + * `sessionStateChange` listener the test can fire, and an optional restore. + */ +function fakeSession (init: { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + restore?: () => Promise +} = {}): any { + const listeners = new Map>() + const session: any = { + isActive: init.isActive, + webId: init.webId, + info: init.info ?? {}, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string) { listeners.get(type)?.forEach(listener => listener()) } + } + if (init.restore) session.restore = init.restore + return session +} + +const flush = async (): Promise => { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + +const collect = (): { events: IdentityEvent[], emit: (event: IdentityEvent) => void } => { + const events: IdentityEvent[] = [] + return { events, emit: (event: IdentityEvent) => { events.push(event) } } +} + +describe('identityState — predicates', () => { + it('treats isActive as authoritative and the WebID as the legacy fallback', () => { + expect(sessionIsActive({ isActive: true })).toBe(true) + expect(sessionIsActive({ isActive: false, webId: 'https://a.example/me' })).toBe(false) + expect(sessionIsActive({ webId: 'https://a.example/me' })).toBe(true) + expect(sessionIsActive({})).toBe(false) + expect(sessionOwnsIdentity({ isActive: false, webId: 'https://a.example/me' })).toBe(false) + expect(sessionOwnsIdentity({ webId: 'https://a.example/me' })).toBe(true) + expect(sessionExplicitlyInactive({ info: { isLoggedIn: false }, webId: 'https://a.example/me' })).toBe(true) + expect(legacySessionInfo({ isActive: false, webId: 'https://a.example/me' })) + .toEqual({ webId: 'https://a.example/me', isLoggedIn: false }) + expect(legacySessionInfo({ webId: 'https://a.example/me' })) + .toEqual({ webId: 'https://a.example/me', isLoggedIn: true }) + }) + + it('classifies transitions, and only replaces an actively established identity', () => { + expect(classifyTransition({ isActive: false }, { isActive: true, webId: 'A' })).toBe('sessionChange') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: false, webId: 'A' })).toBe('logout') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: true, webId: 'B' })).toBe('sessionChange') + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: true, webId: 'A' })).toBe(null) + expect(classifyTransition({ isActive: true, webId: 'A' }, { isActive: false })).toBe('logout') + + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: true, webId: 'B' })).toBe(true) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: false, webId: 'A' })).toBe(true) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: false })).toBe(true) + // Start-up and same-identity refreshes are not replacements. + expect(identityReplaced({ isActive: false }, { isActive: true, webId: 'A' })).toBe(false) + expect(identityReplaced({ isActive: true, webId: 'A' }, { isActive: true, webId: 'A' })).toBe(false) + // A session that already went inactive does not replace twice. + expect(identityReplaced({ isActive: false, webId: 'A' }, { isActive: false })).toBe(false) + expect(identityReplaced({ isActive: false, webId: 'A' }, { isActive: false, webId: 'B' })).toBe(false) + }) +}) + +describe('identityState — session transitions', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + it('reports a login once, and nothing when the identity does not move', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + subscribeIdentity(session, { onEvent: emit }) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange']) + + // A refocus with the same identity costs no event. + document.dispatchEvent(new Event('visibilitychange')) + expect(events).toEqual(['sessionChange']) + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange']) + }) + + it('reports a logout, and one replacement for the identity that was active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + subscribeIdentity(session, { onEvent: emit }) + + session.isActive = false + session.fire('sessionStateChange') + expect(events).toEqual(['logout', 'identityReplaced']) + + // Clearing the retained WebID afterwards is no second replacement (the + // session already went inactive) — the WebID change itself still counts as + // a session change, which consumers only use to invalidate. + session.webId = undefined + session.fire('sessionStateChange') + expect(events).toEqual(['logout', 'identityReplaced', 'sessionChange']) + }) + + it('reports an identity change while the session stays active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + subscribeIdentity(session, { onEvent: emit }) + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) + + it('catches an A to B change that uvdsl never announces, around setTokenDetails', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + // The library's single entry point for token updates, installed before the + // subscription so the watcher wraps it. + const original = vi.fn(async () => { session.webId = 'https://bob.example/me' }) + session.setTokenDetails = original + const { events, emit } = collect() + subscribeIdentity(session, { onEvent: emit }) + + subscribeIdentity(session, {}) // second subscription must not wrap again + await session.setTokenDetails('token') + await flush() + expect(original).toHaveBeenCalledTimes(1) + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) +}) + +describe('identityState — refocus resync', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reports a cross-tab logout once, and does not repeat it on a later refocus', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let restores = 0 + subscribeIdentity(session, { + onEvent: emit, + resync: async () => { + restores += 1 + return 'cleared' + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(1) + expect(events).toEqual(['logout', 'identityReplaced']) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(2) + expect(events).toEqual(['logout', 'identityReplaced']) + }) + + it('joins a resync in flight instead of starting a second one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + let resolveRestore: (value: unknown) => void = () => undefined + let restores = 0 + subscribeIdentity(session, { + resync: () => { + restores += 1 + return new Promise(resolve => { resolveRestore = resolve }) + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(restores).toBe(1) + resolveRestore('changed') + await flush() + }) + + it('drops an answer that belongs to an identity the session has left', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let resolveRestore: (value: unknown) => void = () => undefined + subscribeIdentity(session, { + onEvent: emit, + resync: () => new Promise(resolve => { resolveRestore = resolve }) + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + + // Bob logs in while Alice's restore is still in flight: the attempt's + // answer is about Alice and must not log Bob out. + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(events).toEqual(['sessionChange', 'identityReplaced']) + + resolveRestore('cleared') + await flush() + expect(events).toEqual(['sessionChange', 'identityReplaced']) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://bob.example/me', source: 'session' }) + }) + + it('ignores a cleared answer when the raw identity moved without an event', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + let resolveRestore: (value: unknown) => void = () => undefined + subscribeIdentity(session, { + onEvent: emit, + resync: () => new Promise(resolve => { resolveRestore = resolve }) + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + // No event was dispatched: the watcher never saw this, which is exactly + // the case the raw-identity guard exists for. + session.webId = 'https://bob.example/me' + resolveRestore('cleared') + await flush() + expect(events).toEqual([]) + expect(sessionExplicitlyInactive(session)).toBe(false) + }) +}) + +describe('identityState — cookie identity', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + it('adopts a cookie identity silently as a replacement source, and reports its loss', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + const subscription = subscribeIdentity(session, { onEvent: emit }) + + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual(['sessionChange']) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://cookie.example/profile/card#me', source: 'cookie' }) + + // Replacing a cookie identity is a replacement: the data fetched for the + // previous cookie user has to go. + subscription.reportCookieIdentity('https://other.example/profile/card#me') + expect(events).toEqual(['sessionChange', 'sessionChange', 'identityReplaced']) + + subscription.reportCookieIdentity(null) + expect(events).toEqual(['sessionChange', 'sessionChange', 'identityReplaced', 'sessionChange', 'identityReplaced']) + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('does not replace the identity while the session owns it', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const { events, emit } = collect() + const subscription = subscribeIdentity(session, { onEvent: emit }) + + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual([]) + expect(effectiveIdentity(session)).toEqual({ webId: 'https://alice.example/me', source: 'session' }) + }) + + it('forgets a remembered cookie identity once the session owns one again', () => { + const session = fakeSession({ isActive: false }) + const subscription = subscribeIdentity(session, {}) + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(effectiveIdentity(session).source).toBe('cookie') + + // The session logs in: it owns the identity now, and the remembered cookie + // value must not answer after a later logout. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + session.isActive = false + session.fire('sessionStateChange') + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('ignores a probe that answers after its subscription was released', () => { + const session = fakeSession({ isActive: false }) + const { events, emit } = collect() + const subscription = subscribeIdentity(session, { onEvent: emit }) + + subscription.unsubscribe() + subscription.reportCookieIdentity('https://cookie.example/profile/card#me') + expect(events).toEqual([]) + expect(effectiveIdentity(session)).toEqual({ source: 'none' }) + }) + + it('forwards a refocus to the subscribers and removes the listener with the last one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const onRefocus = vi.fn() + const first = subscribeIdentity(session, { onRefocus }) + + document.dispatchEvent(new Event('visibilitychange')) + expect(onRefocus).toHaveBeenCalledTimes(1) + + first.unsubscribe() + document.dispatchEvent(new Event('visibilitychange')) + expect(onRefocus).toHaveBeenCalledTimes(1) + }) +}) From 05d92a18642c2c57001c558b16e31e7930e0face Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 21:55:34 +0200 Subject: [PATCH 02/11] refactor(auth): read identity, events and lifetime from one state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authSession, SolidAuthnLogic and the fetch bridge now use the per-session identity state (identityState.ts) instead of assembling the identity per call site: - authSession subscribes once (events + the refocus resync) and publishes the derived `info` shape. 'logout' / 'sessionChange' / 'identityReplaced' come from the state; 'login' / 'sessionRestore' stay with checkUser, the only place that knows which path activated the session. - SolidAuthnLogic keeps only what is its own: the redirect handling, the NSS cookie probe and saveUser. currentUser() asks the state for the effective identity, and the probe result goes through its subscription — a probe that answers after dispose(), or while the session took ownership, can no longer resurrect an identity. fallbackWebId, cookieBackedFallback, cookieProbeGeneration, the refocus watcher, probeCookieIdentity() and reportFallbackIdentityChange() are gone: one record replaces them. - solidLogicSingleton decides the fetch credentials from the state, so a session that reports itself inactive (or was reported cleared) cannot send the previous identity's credentials. - AuthnLogic gains the optional dispose() the state's lifetime needs; the legacy event vocabulary is exported and includes the identity events. Tests: the fetch-bridge test fakes the session identity instead of the derived `info` shape, whose assignment is ignored on purpose. Full suite green. --- src/authSession/authSession.ts | 54 ++++++++++++--- src/authSession/events.ts | 6 +- src/authn/SolidAuthnLogic.ts | 111 ++++++++++++++++++++++--------- src/index.ts | 1 + src/logic/solidLogicSingleton.ts | 8 ++- src/types.ts | 6 ++ test/logic.test.ts | 23 +++++-- 7 files changed, 156 insertions(+), 53 deletions(-) diff --git a/src/authSession/authSession.ts b/src/authSession/authSession.ts index 901b0ec..e6dbb14 100644 --- a/src/authSession/authSession.ts +++ b/src/authSession/authSession.ts @@ -14,6 +14,7 @@ import type { Session as OidcSession } from '@uvdsl/solid-oidc-client-browser/co import { _session } from './session' import { resolveIssuerForLogin } from './issuer' import { SessionEvents } from './events' +import { legacySessionInfo, restoreSession, subscribeIdentity, type SessionLike } from './identityState' type SessionCompatibilityShape = { webId?: string @@ -93,22 +94,55 @@ if (originalLogin) { const events = new SessionEvents() -// Emit the legacy 'logout' event when the session transitions from active to inactive. // 'login' and 'sessionRestore' are emitted in SolidAuthnLogic.checkUser() -// because only that call site knows which path activated the session. -let _wasActive = (_session as any).isActive ?? Boolean((_session as any).webId) -if (typeof (_session as unknown as EventTarget).addEventListener === 'function') { - ;(_session as unknown as EventTarget).addEventListener('sessionStateChange', () => { - const isNowActive = (_session as any).isActive ?? Boolean((_session as any).webId) - if (_wasActive && !isNowActive) { - events.emit('logout') - } - _wasActive = isNowActive +// because only that call site knows which path activated the session. Every +// other identity transition is reported by the identity state, which watches +// the session itself: 'logout' when the session goes inactive, 'sessionChange' +// when the identity changes some other way — including a login/logout made in +// another tab, which uvdsl does not broadcast as a state change and which is +// noticed when this tab is refocused — and 'identityReplaced' when an +// established identity is gone. +// +// The resync maps a backing store that no longer holds a session (a cross-tab +// logout) to 'cleared'; the state bounds the wait and drops an answer that +// belongs to an identity the session has left. +const resyncSession = (): unknown => { + const restoring = restoreSession(_session as unknown as SessionLike) + if (!restoring) return undefined + return restoring.then(() => 'changed', (error: unknown) => { + // A transient refresh/network failure is compared as it stands; a store + // that has no session to restore means this tab's identity is gone. + const message = error instanceof Error ? error.message : String(error) + return /no session to restore/i.test(message) ? 'cleared' : 'changed' }) } +subscribeIdentity(_session as unknown as SessionLike, { + onEvent: (event) => events.emit(event), + resync: resyncSession +}) export const authSession: SessionWithLegacyEvents = Object.assign( _session as Omit & { login: LoginCompat }, { events } ) + +// Legacy `info` compatibility shape. +// The uvdsl session stores state on `webId_`/`isActive_` and exposes them via +// `webId`/`isActive` getters, but legacy consumers (e.g. solid-ui's +// `loginStatusBox` widget, `SolidAuthnLogic.currentUser()`'s fallback path) +// read `authSession.info.webId` / `authSession.info.isLoggedIn`. Expose those +// as a derived value — and keep it derived: consumers snapshot and restore +// `info`, and a retained value would report the previous identity after a +// login/logout. Assignment is accepted and ignored so ordinary property +// writes cannot throw; a test that needs to fake `info` redefines it. +Object.defineProperty(authSession, 'info', { + enumerable: true, + configurable: true, + get (): { webId?: string, isLoggedIn?: boolean } { + return legacySessionInfo(_session as unknown as SessionLike) + }, + set (_value: { webId?: string, isLoggedIn?: boolean } | undefined): void { + // Accepted for legacy code that assigns snapshots; reads stay derived. + } +}) \ No newline at end of file diff --git a/src/authSession/events.ts b/src/authSession/events.ts index 8e7704a..e2a6681 100644 --- a/src/authSession/events.ts +++ b/src/authSession/events.ts @@ -5,7 +5,7 @@ * Wired into the auth session by authSession.ts. */ -type LegacyEventName = 'login' | 'logout' | 'sessionRestore' +export type LegacyEventName = 'identityReplaced' | 'login' | 'logout' | 'sessionChange' | 'sessionRestore' type LegacyEventHandler = (...args: unknown[]) => void /** @@ -14,7 +14,8 @@ type LegacyEventHandler = (...args: unknown[]) => void * continue working without modification. * * Events are emitted by SolidAuthnLogic.checkUser() (login/sessionRestore) - * and by the sessionStateChange listener in authSession.ts (logout). + * and by the identity state in identityState.ts ('logout', 'sessionChange', + * 'identityReplaced' — the event the reload helper subscribes to). */ export class SessionEvents { private readonly listeners: Map> = new Map() @@ -32,4 +33,3 @@ export class SessionEvents { this.listeners.get(event)?.forEach(h => h(...args)) } } - diff --git a/src/authn/SolidAuthnLogic.ts b/src/authn/SolidAuthnLogic.ts index 79ceb08..621d833 100644 --- a/src/authn/SolidAuthnLogic.ts +++ b/src/authn/SolidAuthnLogic.ts @@ -1,6 +1,15 @@ import { namedNode, NamedNode, sym } from 'rdflib' import { appContext, offlineTestID } from './authUtil' import * as debug from '../util/debug' +import { + effectiveIdentity, + restoreSession, + sessionOwnsIdentity, + sessionWasCleared, + subscribeIdentity, + type IdentitySubscription, + type SessionLike +} from '../authSession/identityState' import type { SessionWithLegacyEvents } from '../authSession/authSession' import type { AuthenticationContext, AuthnLogic } from '../types' @@ -31,33 +40,63 @@ export class SolidAuthnLogic implements AuthnLogic { private session: SessionWithLegacyEvents private checkUserInFlight: Promise | null = null private sessionRestoreHookAttached = false - private fallbackWebId: string | null = null + /** + * This instance's subscription to the session's identity state. Its lifetime + * IS this instance's: a cookie probe that answers after `dispose()` is + * ignored, and the refocus listener is removed with the last instance using + * the session (see identityState.ts). + */ + private identity: IdentitySubscription - constructor(solidAuthSession: SessionWithLegacyEvents) { + constructor (solidAuthSession: SessionWithLegacyEvents) { this.session = solidAuthSession + // The cookie-backed identity is invisible to the session (it stays inactive + // and WebID-less), so it is re-probed when the tab regains focus: another + // tab may have logged out or switched identity while this one was + // backgrounded. Only meaningful where the probe applies (*.localhost NSS). + // + // TEMPORARY, pending the uvdsl change: this subscription exists because the + // library does not report a cookie identity change (nor a WebID change that + // keeps the session active). See the identityState header. + this.identity = subscribeIdentity(solidAuthSession as unknown as SessionLike, { + onRefocus: () => this.refreshCookieBackedFallback() + }) + } + + /** + * Detaches this instance from the session's identity state: the refocus + * listener is removed with the last instance using the session, and a probe + * that is still in flight can no longer apply its result. + */ + dispose (): void { + this.identity.unsubscribe() + } + + /** + * Re-probes the NSS cookie-backed identity. Skipped while the session owns + * the identity — the probe only exists for the case where it does not — and + * the state drops the result if the session takes ownership while the probe + * is in flight. + */ + private async refreshCookieBackedFallback (): Promise { + if (sessionOwnsIdentity(this.session as unknown as SessionLike)) return + this.identity.reportCookieIdentity(await this.probeNssCookieBackedWebId()) } // we created authSession getter because we want to access it as authn.authSession externally - get authSession(): SessionWithLegacyEvents { return this.session } + get authSession (): SessionWithLegacyEvents { return this.session } - currentUser(): NamedNode | null { + currentUser (): NamedNode | null { const app = appContext() if (app.viewingNoAuthPage) { return sym(app.webId) } - const sessionAny = this.session as any - const infoWebId = sessionAny?.info?.webId - const sessionWebId = sessionAny?.webId - const webId = infoWebId || sessionWebId || this.fallbackWebId - const infoLoggedIn = sessionAny?.info?.isLoggedIn - const sessionActive = sessionAny?.isActive - const isLoggedIn = infoLoggedIn === true || sessionActive === true || - ((infoLoggedIn == null && sessionActive == null) ? Boolean(webId) : false) || - Boolean(this.fallbackWebId) - if (this && this.session && webId && isLoggedIn) { - return sym(webId) - } - return offlineTestID() // null unless testing + // The state answers with the session's identity when it owns one, the + // probed cookie identity when the session is inactive (or cleared), and + // nothing when neither does — a logout that retains the cached WebID must + // not keep answering for the previous user. + const { webId } = effectiveIdentity(this.session as unknown as SessionLike) + return webId ? sym(webId) : offlineTestID() // null unless testing } /** @@ -126,9 +165,13 @@ export class SolidAuthnLogic implements AuthnLogic { // UI would spin forever. Race it against a timeout and treat a stall // as "no previous session" so the page can render the login button. const wasActive = sessionAny?.isActive ?? Boolean(sessionAny?.webId) - if (typeof sessionAny?.restore === 'function') { + // The shared restore lock also covers this call: a refocus resync can be + // in flight at the same time, and two overlapping restores could write an + // older identity back over a newer one. + const restoring = restoreSession(sessionAny) + if (restoring) { try { - await withRestoreTimeout(sessionAny.restore()) + await withRestoreTimeout(restoring) } catch (error) { const message = error instanceof Error ? error.message : String(error) // A failed restore on an inactive session just means "no usable @@ -186,14 +229,13 @@ export class SolidAuthnLogic implements AuthnLogic { let webId = this.webIdFromSession(sessionAny?.info, sessionAny) if (!webId) { - // NSS-specific fallback: recover WebID from NSS cookie session when client restore is empty. - webId = await this.probeNssCookieBackedWebId() - } - - if (webId) { - this.fallbackWebId = webId - } else { - this.fallbackWebId = null + // NSS-specific fallback: recover the WebID from the NSS cookie session + // when the client restore is empty. The result goes through the identity + // state, which drops it if the session took ownership while the probe was + // in flight (or if this instance was disposed meanwhile) — and reports the + // change like any other transition. + this.identity.reportCookieIdentity(await this.probeNssCookieBackedWebId()) + webId = effectiveIdentity(sessionAny).webId ?? null } if (webId) { @@ -278,13 +320,18 @@ export class SolidAuthnLogic implements AuthnLogic { const infoLoggedIn = sessionInfo?.isLoggedIn const rootLoggedIn = sessionRoot?.isLoggedIn const rootActive = sessionRoot?.isActive - if (infoLoggedIn === true || rootLoggedIn === true || rootActive === true) { - return webId - } - if (infoLoggedIn === false && rootLoggedIn === false && rootActive === false) { + // An explicit inactive/not-logged-in flag wins over a cached WebID and + // over a positive flag in another source — the same rule the identity + // state uses (see identityState.ts). The session root has no `isLoggedIn` + // property, so requiring every source to be false kept a cached WebID + // alive across a logout; a mixed snapshot must not resurrect one either. A + // session whose backing store lost it is inactive as well, however + // positive its own fields still look. + if (sessionWasCleared(sessionRoot) || + infoLoggedIn === false || rootLoggedIn === false || rootActive === false) { return null } + // Active, or a legacy session that reports no state at all. return webId } - } diff --git a/src/index.ts b/src/index.ts index 5cbb29c..c38a985 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ const store = solidLogicSingleton.store export { ACL_LINK } from './acl/aclLogic' export { offlineTestID, appContext } from './authn/authUtil' export { performServerSideLogout } from './authn/serverLogout' +export { reloadOnIdentityReplaced } from './authSession/identityState' export { getSuggestedIssuers } from './issuer/issuerLogic' export { createTypeIndexLogic } from './typeIndex/typeIndexLogic' export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ChatLogic } from './types' diff --git a/src/logic/solidLogicSingleton.ts b/src/logic/solidLogicSingleton.ts index 8320b6f..41620c4 100644 --- a/src/logic/solidLogicSingleton.ts +++ b/src/logic/solidLogicSingleton.ts @@ -1,12 +1,18 @@ import * as debug from '../util/debug' import { authSession } from '../authSession/authSession' +import { sessionExplicitlyInactive } from '../authSession/identityState' import { createSolidLogic } from './solidLogic' import { SolidLogic } from '../types' const _fetch = async (url, requestInit) => { const omitCreds = requestInit && requestInit.credentials && requestInit.credentials == 'omit' const sessionAny = authSession as any - const sessionWebId = sessionAny?.info?.webId || sessionAny?.webId + // A session that explicitly reports itself inactive must not keep + // identifying the last user: with a retained WebID, choosing the + // authenticated fetch would send the previous identity's credentials. + const sessionWebId = sessionExplicitlyInactive(sessionAny) + ? undefined + : (sessionAny?.info?.webId || sessionAny?.webId) if (sessionWebId && !omitCreds) { // see https://github.com/solidos/solidos/issues/114 // In fact fetch should respect credentials omit itself const authenticatedFetch = (typeof sessionAny.fetch === 'function') diff --git a/src/types.ts b/src/types.ts index 58ebd61..8101761 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,12 @@ export interface AuthnLogic { checkUser: (setUserCallback?: (me: NamedNode | null) => T) => Promise saveUser: (webId: NamedNode | string | null, context?: AuthenticationContext) => NamedNode | null + /** + * Releases what the implementation registered elsewhere (document and + * session listeners). Optional so other implementations stay valid, but a + * caller that replaces a logic instance should dispose the old one. + */ + dispose?: () => void } export interface SolidNamespace { diff --git a/test/logic.test.ts b/test/logic.test.ts index 14bad48..bab2e33 100644 --- a/test/logic.test.ts +++ b/test/logic.test.ts @@ -35,7 +35,17 @@ describe('solidLogicSingleton fetch bridge', () => { let originalFetch: any let originalAuthFetch: any - let originalInfo: any + + // Whose credentials a request would carry is decided by the identity state, + // which reads the session itself — so the test fakes the session identity, + // not the derived `info` shape (whose assignment is ignored on purpose). + // `webId`/`isActive` are prototype getters on the real session, so an own + // property shadows them for the duration of a test. + const setSessionIdentity = (webId?: string): void => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'isActive', { value: webId !== undefined, configurable: true }) + Object.defineProperty(sessionAny, 'webId', { value: webId, configurable: true }) + } beforeEach(() => { fetchMock.resetMocks() @@ -43,21 +53,21 @@ describe('solidLogicSingleton fetch bridge', () => { const sessionAny = authSession as any originalFetch = sessionAny.fetch originalAuthFetch = sessionAny.authFetch - originalInfo = sessionAny.info - sessionAny.info = { isLoggedIn: false } + setSessionIdentity() }) afterEach(() => { const sessionAny = authSession as any sessionAny.fetch = originalFetch sessionAny.authFetch = originalAuthFetch - sessionAny.info = originalInfo + delete sessionAny.webId + delete sessionAny.isActive }) it('uses window.fetch when credentials are omit even if a session exists', async () => { const sessionAny = authSession as any - sessionAny.info = { webId: 'https://alice.example/profile#me', isLoggedIn: true } + setSessionIdentity('https://alice.example/profile#me') sessionAny.fetch = vi.fn().mockResolvedValue(new Response('session')) fetchMock.mockResponseOnce('window') @@ -70,7 +80,7 @@ describe('solidLogicSingleton fetch bridge', () => { it('falls back to authFetch when session.fetch is unavailable', async () => { const sessionAny = authSession as any - sessionAny.info = { webId: 'https://alice.example/profile#me', isLoggedIn: true } + setSessionIdentity('https://alice.example/profile#me') sessionAny.fetch = undefined sessionAny.authFetch = vi.fn().mockResolvedValue(new Response('auth')) @@ -80,4 +90,3 @@ describe('solidLogicSingleton fetch bridge', () => { expect(fetchMock).not.toHaveBeenCalled() }) }) - From a3e8b7a38b649b5338abd3fe8fe3d45d49202e0e Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 22:00:14 +0200 Subject: [PATCH 03/11] test(auth): pin the wiring-level identity behaviours Replaces the smoke tests with the behaviours the identity state has to keep for SolidAuthnLogic: - currentUser(): the active session's WebID; logged out for an explicit isActive:false or info.isLoggedIn:false with a cached WebID; the legacy WebID-only shape accepted. - checkUser(): sessionRestore/login announced once, from the path that activated the session; "No session to restore." is treated as logged out while a failure that nevertheless left the session active is rethrown; the NSS cookie probe recovers the WebID on *.localhost and reports it as a session change, is skipped when the session already has one, and a probe that answers after the session took the identity is ignored. - refocus/dispose: the cookie identity is re-probed and its loss reported; a disposed instance stops probing (an in-flight probe result is dropped), while a second instance using the same session keeps the watcher. - authSession.info: derived, explicit false reported, assignment ignored. The tests subscribe a forwarder like authSession does in the app, so the emitted list means the same thing as the legacy events consumers see. --- test/solidAuthLogic.test.ts | 363 ++++++++++++++++++++++++++++++++---- 1 file changed, 323 insertions(+), 40 deletions(-) diff --git a/test/solidAuthLogic.test.ts b/test/solidAuthLogic.test.ts index aed3c05..511cebd 100644 --- a/test/solidAuthLogic.test.ts +++ b/test/solidAuthLogic.test.ts @@ -1,55 +1,338 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SolidAuthnLogic } from '../src/authn/SolidAuthnLogic' +import { authSession } from '../src/authSession/authSession' +import { subscribeIdentity, type IdentitySubscription } from '../src/authSession/identityState' import { silenceDebugMessages } from './helpers/debugger' -import { AuthenticationContext } from '../src/types' -import { EventEmitter } from 'node:events' silenceDebugMessages() -let solidAuthnLogic: SolidAuthnLogic -const authSession = { - events: new EventEmitter(), - addEventListener (event: string | symbol, listener: (...args: any[]) => void) { - this.events.on(event, listener) - }, - removeEventListener (event: string | symbol, listener: (...args: any[]) => void) { - this.events.off(event, listener) - }, + +type Listener = (...args: any[]) => void + +/** + * A fake uvdsl-style session: properties the identity state reads, a + * `sessionStateChange` listener, and the legacy `events` emitter the logic + * (and the state's subscription) publish through. + */ +function fakeSession (init: { + isActive?: boolean + webId?: string + info?: { isLoggedIn?: boolean, webId?: string } + restore?: () => Promise + handleRedirectFromLogin?: () => Promise +} = {}): any { + const listeners = new Map>() + const emitted: string[] = [] + const session: any = { + isActive: init.isActive, + webId: init.webId, + info: init.info, + restore: init.restore, + handleRedirectFromLogin: init.handleRedirectFromLogin, + emitted, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { + listeners.get(type)?.forEach(listener => listener()) + }, + events: { + on (_type: string, _listener: Listener): void { /* registered by checkUser */ }, + emit (type: string): void { emitted.push(type) } + } + } + return session +} + +const flush = async (): Promise => { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + +// jsdom keeps its own location; the NSS probe reads hostname/port/protocol from +// it, so tests that exercise the probe replace it for their duration. +const originalLocation = Object.getOwnPropertyDescriptor(window, 'location') + +const setLocation = (hostname: string): void => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + hostname, + port: '', + protocol: 'http:', + href: `http://${hostname}/`, + toString: () => `http://${hostname}/` + } + }) +} + +// Every instance a test creates is disposed afterwards: the identity state +// keeps ONE document listener per session, and jsdom's document is shared by +// the whole file — a surviving subscription would probe during later tests. +let instances: SolidAuthnLogic[] = [] +let forwarders: IdentitySubscription[] = [] + +const createAuthn = (session: any): SolidAuthnLogic => { + // The app forwards the state's events through authSession's subscription; + // a fake session needs the same forwarder for the emitted list to mean + // anything. + forwarders.push(subscribeIdentity(session, { onEvent: (event) => session.emitted.push(event) })) + const authn = new SolidAuthnLogic(session) + instances.push(authn) + return authn +} + +afterEach(() => { + instances.forEach(authn => authn.dispose()) + instances = [] + forwarders.forEach(subscription => subscription.unsubscribe()) + forwarders = [] +}) + +const okProbe = (): void => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 403 }))) } -describe('SolidAuthnLogic', () => { - +describe('SolidAuthnLogic — currentUser', () => { + it('returns the WebID of an active session', () => { + const authn = createAuthn(fakeSession({ isActive: true, webId: 'https://alice.example/me' })) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) + + it('reports logged out when the session explicitly went inactive, even with a cached WebID', () => { + const authn = createAuthn(fakeSession({ isActive: false, webId: 'https://alice.example/me' })) + expect(authn.currentUser()).toBe(null) + }) + + it('reports logged out when the legacy info says so, even with a cached WebID', () => { + const authn = createAuthn(fakeSession({ + webId: 'https://alice.example/me', + info: { isLoggedIn: false, webId: 'https://alice.example/me' } + })) + expect(authn.currentUser()).toBe(null) + }) + + it('accepts a legacy session that reports no state at all but carries a WebID', () => { + const authn = createAuthn(fakeSession({ webId: 'https://alice.example/me' })) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) +}) + +describe('SolidAuthnLogic — checkUser', () => { + beforeEach(() => { + setLocation('localhost') + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalLocation) Object.defineProperty(window, 'location', originalLocation) + }) + + it('exists and runs', async () => { + const authn = createAuthn(fakeSession()) + expect(authn.checkUser).toBeInstanceOf(Function) + expect(await authn.checkUser()).toEqual(null) + }) + + it('activates a session from a restore and announces sessionRestore once', async () => { + const session = fakeSession({ isActive: false }) + session.restore = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + } + const authn = createAuthn(session) + + expect((await authn.checkUser() as any)?.value).toBe('https://alice.example/me') + expect(session.emitted).toEqual(['sessionRestore']) + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + }) + + it('announces login after a redirect, not sessionRestore', async () => { + const session = fakeSession({ isActive: false }) + session.handleRedirectFromLogin = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + } + const authn = createAuthn(session) + + await authn.checkUser() + expect(session.emitted).toEqual(['login']) + }) + + it('treats "No session to restore." as logged out instead of failing', async () => { + const session = fakeSession({ isActive: false, restore: async () => { throw new Error('No session to restore.') } }) + const authn = createAuthn(session) + + await expect(authn.checkUser()).resolves.toEqual(null) + expect(session.emitted).toEqual([]) + }) + + it('rethrows a restore failure when the session nevertheless became active', async () => { + const session = fakeSession({ isActive: false }) + session.restore = async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + throw new Error('refresh failed') + } + const authn = createAuthn(session) + + await expect(authn.checkUser()).rejects.toThrow('refresh failed') + }) + + it('recovers the NSS cookie WebID when the client restore is empty, and reports it as a session change', async () => { + setLocation('alice.localhost') + okProbe() + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + expect((await authn.checkUser() as any)?.value).toBe('http://alice.localhost/profile/card#me') + expect(authn.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') + // Adopting a cookie identity is a change, not a replacement: there was no + // previous user to drop. + expect(session.emitted).toEqual(['sessionChange']) + }) + + it('ignores a cookie probe that answers after the session took the identity', async () => { + setLocation('alice.localhost') + let resolveFetch: (value: Response) => void = () => undefined + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { resolveFetch = resolve }))) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + const checking = authn.checkUser() + await flush() + // The session logs in while the probe is in flight. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + resolveFetch(new Response(null, { status: 403 })) + await checking + + expect(authn.currentUser()?.value).toBe('https://alice.example/me') + expect(session.emitted).toEqual(['sessionChange']) + }) + + it('does not probe when the session already has a WebID', async () => { + setLocation('alice.localhost') + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const authn = createAuthn(session) + + await authn.checkUser() + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('SolidAuthnLogic — refocus and dispose', () => { beforeEach(() => { - solidAuthnLogic = new SolidAuthnLogic(authSession as any) + setLocation('alice.localhost') + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalLocation) Object.defineProperty(window, 'location', originalLocation) + }) + + it('re-probes the cookie identity on refocus and reports it when it is gone', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(authn.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') + + // The cookie session is gone in another tab. + fetchMock.mockImplementation(async () => new Response(null, { status: 200 })) + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(authn.currentUser()).toBe(null) + expect(session.emitted).toEqual(['sessionChange', 'sessionChange', 'identityReplaced']) + }) + + it('stops probing once the last instance using the session is disposed', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + authn.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps the refocus probe for the remaining instance', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: false }) + const first = createAuthn(session) + const second = createAuthn(session) + + first.dispose() + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(second.currentUser()?.value).toBe('http://alice.localhost/profile/card#me') }) - describe('checkUser', () => { - it('exists', () => { - expect(solidAuthnLogic.checkUser).toBeInstanceOf(Function) - }) - it('runs', async () => { - expect(await solidAuthnLogic.checkUser()).toEqual(null) - }) + it('drops a probe result that was in flight when the instance was disposed', async () => { + let resolveFetch: (value: Response) => void = () => undefined + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { resolveFetch = resolve }))) + const session = fakeSession({ isActive: false }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + authn.dispose() + resolveFetch(new Response(null, { status: 403 })) + await flush() + + expect(authn.currentUser()).toBe(null) + expect(session.emitted).toEqual([]) }) - describe('currentUser', () => { - it('exists', () => { - expect(solidAuthnLogic.currentUser).toBeInstanceOf(Function) - }) - it('runs', async () => { - expect(await solidAuthnLogic.currentUser()).toEqual(null) - }) + it('does not touch the identity while the session owns it', async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const authn = createAuthn(session) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(fetchMock).not.toHaveBeenCalled() + expect(authn.currentUser()?.value).toBe('https://alice.example/me') }) +}) - describe('saveUser', () => { - it('exists', () => { - expect(solidAuthnLogic.saveUser).toBeInstanceOf(Function) - }) - it('runs', () => { - expect(solidAuthnLogic.saveUser( - '', - {} as AuthenticationContext - )).toEqual(null) - }) +describe('authSession.info', () => { + afterEach(() => { + const sessionAny = authSession as any + delete sessionAny.webId + delete sessionAny.isActive }) -}) \ No newline at end of file + it('is derived from the session, and assignment is ignored', () => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'webId', { value: 'https://alice.example/me', configurable: true }) + Object.defineProperty(sessionAny, 'isActive', { value: true, configurable: true }) + + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: true }) + + // Legacy code assigns snapshots; reads stay derived so a retained value + // cannot answer for the session. + sessionAny.info = { webId: 'https://mallory.example/me', isLoggedIn: true } + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: true }) + }) + + it('reports logged out when the session explicitly went inactive', () => { + const sessionAny = authSession as any + Object.defineProperty(sessionAny, 'webId', { value: 'https://alice.example/me', configurable: true }) + Object.defineProperty(sessionAny, 'isActive', { value: false, configurable: true }) + + expect(sessionAny.info).toEqual({ webId: 'https://alice.example/me', isLoggedIn: false }) + }) +}) From cd5e02f2be481c5eb94eb2810de57b83e151c292 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 22:03:17 +0200 Subject: [PATCH 04/11] feat(auth): invalidate and repair cached authorization from the state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session transition changes whose credentials a request would carry, but editable() reads responses that are not keyed by identity: a document fetched anonymously (before a restore completed) or under a previous WebID keeps answering for the old identity. flagAuthorizationOnSessionTransitions() subscribes to the identity state — one call per applied transition, whichever path noticed it — and marks every recorded response out-of-date, so editable() answers "unknown" instead of the previous identity's access. A failed invalidation is remembered as refreshRequired, so the decision points repair instead of trusting a store that could not be invalidated. refreshDocumentAuthorization() / ensureDocumentAuthorization() / loadAuthorizedDocument() force-refresh (force: true, clearPreviousData: true) with the store's transition generation stamped around each attempt: an answer overtaken by a transition is retried under the new identity and stays "unknown" when it cannot be established — never the previous identity's answer. loadAuthorizedDocument also generation-checks the load, because a response begun before a transition can be recorded after it. utilityLogic's followOrCreate* repair before reading (and fail closed with NotEditableError), and solidLogic wires the invalidation where the session is created. identityState reports an applied transition through onTransition (once), while onEvent still carries the individual legacy events: a transition that carries two events must not make a consumer that only invalidates do its work twice. On rdflib 2.4.0 a plain load cannot repair a flagged document (the literal / NamedNode mismatch, linkeddata/rdflib.js#427); from 2.4.1 load() matches the literal, so checkEditable() heals too — the force path remains the deterministic repair on both. Tests: 12 for the store side (flag on change only, every transition path, release, failed invalidation, definitive answers, repair, overtaken load, failure modes). --- .../flagAuthorizationOnTransitions.ts | 234 ++++++++++++++++++ src/authSession/identityState.ts | 31 ++- src/logic/solidLogic.ts | 7 + src/util/utilityLogic.ts | 21 +- test/flagAuthorizationOnTransitions.test.ts | 219 ++++++++++++++++ 5 files changed, 501 insertions(+), 11 deletions(-) create mode 100644 src/authSession/flagAuthorizationOnTransitions.ts create mode 100644 test/flagAuthorizationOnTransitions.test.ts diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts new file mode 100644 index 0000000..b77e191 --- /dev/null +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -0,0 +1,234 @@ +/** + * Session transitions invalidate the store's cached HTTP authorization + * metadata. + * + * `UpdateManager.editable()` is a synchronous read of the responses recorded + * under `fetcher.appNode`. Those responses are not keyed by identity, so a + * document fetched anonymously (before a restore completed) or under a + * previous WebID keeps answering for the old identity: a writable document + * can look read-only after login, and a read-only one can look writable after + * logout. + * + * `UpdateManager.flagAuthorizationMetadata()` marks every recorded response + * out-of-date, so `editable()` answers "unknown" instead of the previous + * identity's access. It is wired HERE, once, subscribed to the session's + * identity state — so it covers every transition whichever path noticed it + * (a session event, a token update, a refocus resync, a cookie probe), rather + * than whichever events a call site remembered to listen for. + * + * A document is repaired by a FORCE refresh — `fetcher.refresh()` sets + * `force: true, clearPreviousData: true` and records a fresh response — + * wrapped by `refreshDocumentAuthorization()` below for call sites that need + * the answer immediately. On rdflib 2.4.0 a plain load cannot repair it: + * `load()` looked the recorded request up as a NamedNode while the fetcher + * records a string literal (linkeddata/rdflib.js#427) and answered from the + * cache. From 2.4.1 `load()` matches the literal and refetches a document + * whose recorded answers are all flagged, so `checkEditable()` heals too — + * the force path stays because it is deterministic and works on both. + */ + +import * as debug from '../util/debug' +import { subscribeIdentity, type SessionLike } from './identityState' + +export type TransitionStore = { + updater?: { flagAuthorizationMetadata?: () => void } +} + +/** + * Flags the store's authorization metadata on every identity transition. + * + * @returns the unsubscribe function — the caller owns the lifetime (it ends + * with the session's subscription). + */ +export function flagAuthorizationOnSessionTransitions ( + store: TransitionStore, + session: SessionLike +): () => void { + const flag = (): void => { + const state = storeState(store) + state.generation += 1 + try { + const invalidate = store.updater?.flagAuthorizationMetadata + if (typeof invalidate !== 'function') { + // A store without the API cannot be invalidated — that is a failure, + // not a success: the decision points must not trust its answers. + throw new Error('flagAuthorizationMetadata is unavailable') + } + invalidate.call(store.updater) + // Every recorded response is invalidated; decision points see that as + // "unknown" and repair from there. + state.refreshRequired = false + } catch (error) { + // The store could not invalidate its metadata, so its answers stay + // definitive for the previous identity. Do not take the session + // handling down with it, but do not treat the warning as recovery + // either: record that a fresh response is required and have the + // decision points honour it (ensureDocumentAuthorization below). + state.refreshRequired = true + debug.warn(`Could not flag authorization metadata after a session transition: ${error}`) + } + } + // One call per applied transition (not per event: a logout that also + // replaces the identity is one invalidation). + const subscription = subscribeIdentity(session, { onTransition: () => flag() }) + return () => subscription.unsubscribe() +} + +export type RefreshableStore = { + fetcher?: { + refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown + load?: (doc: unknown) => unknown + } + updater?: { editable?: (uri: unknown) => string | boolean | undefined } +} + +type StoreAuthorizationState = { + /** Identity transitions observed for this store. */ + generation: number + /** The store could not invalidate its metadata — do not trust its answers. */ + refreshRequired: boolean +} + +// Scoped per store: two `createSolidLogic` instances with different sessions +// must not overtake each other's refreshes, and a failed invalidation in one +// store says nothing about another. +const storeStates = new WeakMap() +const sharedState: StoreAuthorizationState = { generation: 0, refreshRequired: false } + +function storeState (store: unknown): StoreAuthorizationState { + if (store === null || typeof store !== 'object') return sharedState + let state = storeStates.get(store) + if (!state) { + state = { generation: 0, refreshRequired: false } + storeStates.set(store, state) + } + return state +} + +/** How many times a refresh is repeated when the identity keeps changing. */ +const REFRESH_ATTEMPTS = 3 + +/** + * Force-refresh one document and answer its editability under the current + * identity — the repair path for a flagged store (see above). It costs a + * round-trip; decision points that need an immediate, correct answer use it. + * + * The identity can change while the refresh is in flight; the response then + * belongs to the previous identity and must not answer for the current one, + * or a caller could write under the new identity on the old identity's + * authorization. Each attempt is stamped with the store's transition + * generation and repeated under the new identity when it was overtaken; if + * the identity keeps changing the answer stays "unknown" rather than stale. + * + * Returns `undefined` whenever the answer cannot be established under the + * current identity: no refresh capability, a failed refresh, or an identity + * that changed throughout every attempt. A failed refresh must NOT fall back + * to the recorded answer — when the store could not be invalidated that + * answer belongs to the previous identity. + */ +export async function refreshDocumentAuthorization ( + store: RefreshableStore, + doc: unknown +): Promise { + const state = storeState(store) + for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { + const generation = state.generation + const refreshed = await forceRefresh(store, doc) + if (!refreshed) return undefined + // The read below is synchronous, so a generation that still matches means + // no transition slipped in between the response and the answer. + if (generation === state.generation) { + return store.updater?.editable?.(doc) + } + } + return undefined +} + +/** + * Make the store able to answer for `doc` under the current identity before + * its cached triples are read or its editability gates a write. A flagged + * store answers `undefined` and is repaired here; a store whose flag FAILED + * still answers definitively for the previous identity, so it is repaired + * too (and keeps being repaired until a later transition flags successfully, + * since the failure says nothing about which other documents are stale). + * + * Returns whether the answer was established. `false` means a repair was + * needed and could not complete (no refresh capability, a failed refresh, or + * an identity that changed throughout): the caller must not consume cached + * triples from that document and must not offer a write on it. + */ +export async function ensureDocumentAuthorization ( + store: RefreshableStore, + doc: unknown +): Promise { + const state = storeState(store) + if (!state.refreshRequired && store.updater?.editable?.(doc) !== undefined) { + return true + } + return (await refreshDocumentAuthorization(store, doc)) !== undefined +} + +/** + * Load a document and make sure its cached triples can be read under the + * current identity. The load itself is generation-checked: a response begun + * under the previous identity can be recorded AFTER + * `flagAuthorizationMetadata()` ran (the flag only marks response nodes that + * already existed), which leaves a definitive-looking answer from the old + * identity behind — so an overtaken load is force-refreshed instead of being + * trusted. + * + * Returns whether the document can be consumed (see + * ensureDocumentAuthorization). Load errors propagate, as a plain `load()` + * would. + */ +export async function loadAuthorizedDocument ( + store: RefreshableStore, + doc: unknown +): Promise { + const state = storeState(store) + const generation = state.generation + await store.fetcher?.load?.(doc) + if (generation !== state.generation) { + return (await refreshDocumentAuthorization(store, doc)) !== undefined + } + return ensureDocumentAuthorization(store, doc) +} + +/** + * rdflib's `refresh(term, callback)` is callback-based and returns void — + * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: + * true }, callback)` and the callback is the completion signal. Awaiting the + * call itself would read `editable()` before the fresh response is recorded, + * so wait for the callback (a promise-returning wrapper is awaited too). + * + * Resolves `true` only when a refresh actually completed; a missing refresh + * capability, a callback that reports failure, a rejected promise or a + * synchronous throw all resolve `false`, with a warning — the caller must not + * read the recorded answer in that case. + */ +async function forceRefresh (store: RefreshableStore, doc: unknown): Promise { + const refresh = store.fetcher?.refresh + if (typeof refresh !== 'function') return false + return await new Promise((resolve) => { + let settled = false + const done = (ok?: unknown, message?: unknown): void => { + if (settled) return + settled = true + if (ok === false) { + debug.warn(`Could not refresh ${String(doc)}: ${String(message)}`) + resolve(false) + } else { + resolve(true) + } + } + try { + const result = refresh.call(store.fetcher, doc, done) + if (result && typeof (result as Promise).then === 'function') { + void (result as Promise).then(() => done(), (error) => done(false, error)) + } + } catch (error) { + debug.warn(`Could not refresh ${String(doc)}: ${String(error)}`) + done(false) + } + }) +} diff --git a/src/authSession/identityState.ts b/src/authSession/identityState.ts index e780512..250c74a 100644 --- a/src/authSession/identityState.ts +++ b/src/authSession/identityState.ts @@ -119,6 +119,8 @@ const RESYNC_TIMEOUT_MS = 2000 type Subscriber = { onEvent?: (event: IdentityEvent) => void + /** The whole transition, once — for consumers that only invalidate. */ + onTransition?: (events: IdentityEvent[]) => void onRefocus?: () => void | Promise resync?: () => unknown } @@ -279,8 +281,17 @@ export function restoreSession (session: SessionLike | undefined): Promise { - record.subscribers.forEach(subscriber => subscriber.onEvent?.(event)) +/** + * Reports one applied transition. `onEvent` receives each event separately + * (the legacy vocabulary); `onTransition` receives the whole transition once, + * so a consumer that only invalidates does not do its work twice when a + * transition carries two events (a logout that also replaces the identity). + */ +const deliver = (record: Record, events: IdentityEvent[]): void => { + record.subscribers.forEach(subscriber => { + events.forEach(event => subscriber.onEvent?.(event)) + subscriber.onTransition?.(events) + }) } /** @@ -307,8 +318,10 @@ function note (record: Record): void { // The session moved on: an attempt that started before this transition is // answering about a state that no longer holds. record.version += 1 - if (event) emit(record, event) - if (replaced) emit(record, 'identityReplaced') + const events: IdentityEvent[] = [] + if (event) events.push(event) + if (replaced) events.push('identityReplaced') + deliver(record, events) } /** @@ -328,7 +341,7 @@ function reportCleared (record: Record): void { record.raw = snapshotOf(record) if (wasActive) { record.version += 1 - emit(record, 'logout') + deliver(record, ['logout']) } return } @@ -339,8 +352,7 @@ function reportCleared (record: Record): void { record.cleared = true record.raw = snapshotOf(record) record.version += 1 - emit(record, 'logout') - emit(record, 'identityReplaced') + deliver(record, ['logout', 'identityReplaced']) } /** @@ -548,11 +560,12 @@ function applyCookieIdentity (record: Record, webId: string | null): void { if (sessionOwnsIdentity(record.session)) return const previousCookieBacked = record.cookieWebId !== null record.cookieWebId = webId - emit(record, 'sessionChange') // The replacement is owed whenever the identity being REPLACED was // cookie-backed — the session watcher could not see it. Adopting one is not // a replacement: there was nothing of a previous user to drop. - if (previousCookieBacked) emit(record, 'identityReplaced') + const events: IdentityEvent[] = ['sessionChange'] + if (previousCookieBacked) events.push('identityReplaced') + deliver(record, events) } /** diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index 5150d92..d653233 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -3,6 +3,7 @@ import { LiveStore, NamedNode, Statement } from 'rdflib' import { createAclLogic } from '../acl/aclLogic' import { SolidAuthnLogic } from '../authn/SolidAuthnLogic' import type { SessionWithLegacyEvents } from '../authSession/authSession' +import { flagAuthorizationOnSessionTransitions } from '../authSession/flagAuthorizationOnTransitions' import { createChatLogic } from '../chat/chatLogic' import { createInboxLogic } from '../inbox/inboxLogic' import { createResourceLogic } from '../resource/resourceLogic' @@ -25,6 +26,12 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: rdf.fetcher(store, {fetch: specialFetch.fetch}) // Attach a web I/O module, store.fetcher store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater store.features = [] // disable automatic node merging on store load + // Whose credentials a request would carry changed: mark every recorded + // response out-of-date so editability answers "unknown" instead of the + // previous identity's access. Decision points repair with + // ensureDocumentAuthorization() (see flagAuthorizationOnTransitions.ts). + // The subscription lives as long as the identity state's, i.e. the session's. + flagAuthorizationOnSessionTransitions(store, session) const authn: AuthnLogic = new SolidAuthnLogic(session) diff --git a/src/util/utilityLogic.ts b/src/util/utilityLogic.ts index f5b7e87..91e60ce 100644 --- a/src/util/utilityLogic.ts +++ b/src/util/utilityLogic.ts @@ -1,4 +1,5 @@ import { NamedNode, st, sym } from 'rdflib' +import { loadAuthorizedDocument } from '../authSession/flagAuthorizationOnTransitions' import { CrossOriginForbiddenError, FetchError, @@ -89,7 +90,15 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { object: NamedNode, doc: NamedNode ): Promise { - await store.fetcher.load(doc) + // A response begun before an identity transition can be recorded after it, + // and on rdflib 2.4.0 a plain load does not refetch a flagged document: the + // helper owns the load, checks it was not overtaken and repairs before + // anything is read (see flagAuthorizationOnTransitions.ts). + if (!(await loadAuthorizedDocument(store, doc))) { + const msg = `followOrCreateLink: cannot establish the authorization of ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode @@ -123,7 +132,15 @@ export function createUtilityLogic(store, aclLogic, containerLogic) { doc: NamedNode, data: string ): Promise { - await store.fetcher.load(doc) + // A response begun before an identity transition can be recorded after it, + // and on rdflib 2.4.0 a plain load does not refetch a flagged document: the + // helper owns the load, checks it was not overtaken and repairs before + // anything is read (see flagAuthorizationOnTransitions.ts). + if (!(await loadAuthorizedDocument(store, doc))) { + const msg = `followOrCreateLinkWithContentOnCreate: cannot establish the authorization of ${doc.value}` + debug.warn(msg) + throw new NotEditableError(msg) + } const result = store.any(subject, predicate, null, doc) if (result) return result as NamedNode diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts new file mode 100644 index 0000000..9bacbf2 --- /dev/null +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ensureDocumentAuthorization, + flagAuthorizationOnSessionTransitions, + loadAuthorizedDocument, + refreshDocumentAuthorization +} from '../src/authSession/flagAuthorizationOnTransitions' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() + +type Listener = () => void + +function fakeSession (init: { isActive?: boolean, webId?: string } = {}): any { + const listeners = new Map>() + const session: any = { + isActive: init.isActive, + webId: init.webId, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { listeners.get(type)?.forEach(listener => listener()) } + } + return session +} + +/** A store whose fetcher answers `refresh` with a completion callback. */ +function fakeStore (options: { + flagFails?: boolean + editable?: string | boolean | undefined + refresh?: (doc: unknown, done: (ok?: unknown, message?: unknown) => void) => unknown + load?: (doc: unknown) => unknown +} = {}): any { + const editable = options.editable ?? 'N3PATCH' + return { + updater: { + flagAuthorizationMetadata: vi.fn(() => { + if (options.flagFails) throw new Error('no metadata support') + }), + editable: vi.fn(() => editable) + }, + fetcher: { + refresh: vi.fn((doc: unknown, done: any) => { + if (options.refresh) return options.refresh(doc, done) + done(true) + }), + load: vi.fn(async (doc: unknown) => { + if (options.load) await options.load(doc) + }) + } + } +} + +const doc = 'https://example.org/doc' + +let unsubscribes: Array<() => void> = [] + +const connect = (store: any, session: any): void => { + unsubscribes.push(flagAuthorizationOnSessionTransitions(store, session)) +} + +afterEach(() => { + unsubscribes.forEach(unsubscribe => unsubscribe()) + unsubscribes = [] +}) + +describe('flagAuthorizationOnSessionTransitions', () => { + it('flags the store when the identity changes, and only then', () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore() + connect(store, session) + + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + + // A refocus or event with no change costs nothing. + session.fire('sessionStateChange') + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(1) + }) + + it('covers logout and an identity change while active', () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const store = fakeStore() + connect(store, session) + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + session.isActive = false + session.fire('sessionStateChange') + + expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(2) + }) + + it('stops flagging once the subscription is released', () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore() + connect(store, session) + unsubscribes.pop()!() // release the one this test created + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() + }) + + it('remembers a failed invalidation, so the decision points repair instead of trusting it', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ flagFails: true }) + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(1) + + // The store could not be invalidated: its recorded answer belongs to the + // previous identity, so it must be refreshed even though editable() answers. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) + }) + + it('trusts a definitive answer that was not invalidated', async () => { + const store = fakeStore({ editable: false }) + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + expect(store.fetcher.refresh).not.toHaveBeenCalled() + }) +}) + +describe('refreshDocumentAuthorization', () => { + it('forces the refresh and answers under the current identity', async () => { + const store = fakeStore() + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBe('N3PATCH') + expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) + expect(store.updater.editable).toHaveBeenCalledTimes(1) + }) + + it('stays unknown when the refresh fails instead of answering from the recorded copy', async () => { + const store = fakeStore({ refresh: (_doc, done) => { done(false, 'network down') } }) + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() + expect(store.updater.editable).not.toHaveBeenCalled() + }) + + it('gives up as unknown when the identity keeps changing under it', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const store = fakeStore({ + refresh: (_doc, done) => { + // The identity moves on while the response is on its way. + session.webId = session.webId === 'https://alice.example/me' + ? 'https://bob.example/me' + : 'https://alice.example/me' + session.fire('sessionStateChange') + done(true) + } + }) + connect(store, session) + + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() + expect(store.fetcher.refresh).toHaveBeenCalledTimes(3) + expect(store.updater.editable).not.toHaveBeenCalled() + }) + + it('has no answer when the store cannot refresh at all', async () => { + const store: any = { updater: { editable: vi.fn(() => 'N3PATCH') } } + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() + }) +}) + +describe('loadAuthorizedDocument', () => { + it('consumes a load that was not overtaken by a transition', async () => { + const store = fakeStore() + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).toHaveBeenCalledTimes(1) + expect(store.fetcher.refresh).not.toHaveBeenCalled() + }) + + it('repairs a load that a transition overtook', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ + load: () => { + // A login lands while the response is in flight: the flag only marks + // responses that already existed, so this one would look definitive. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + } + }) + connect(store, session) + + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) + expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) + }) + + it('reports false when the repair cannot be established', async () => { + const session = fakeSession({ isActive: false }) + const store: any = { + updater: { + flagAuthorizationMetadata: vi.fn(), + editable: vi.fn(() => 'N3PATCH') + }, + // no fetcher.refresh: nothing can be re-answered + fetcher: { + load: vi.fn(async () => { + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + }) + } + } + connect(store, session) + + await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(false) + }) +}) From dcafca2fdb2f4770c5a797a21d63aad59bee51b9 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 18 Sep 2026 22:05:55 +0200 Subject: [PATCH 05/11] chore: move to rdflib 2.4.1 and pin the healed contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rdflib 2.4.1 carries linkeddata/rdflib.js#871: load() matches the recorded request by its URI literal and refetches a document whose recorded answers are all flagged, so a plain load()/checkEditable() heals a flagged document again. Our repair path (refreshDocumentAuthorization/ensureDocumentAuthorization/ loadAuthorizedDocument) is unchanged and stays the deterministic repair — it also covers consumers still on rdflib 2.4.0. Contract tests against the real UpdateManager/Fetcher: - definitive -> unknown when flagged -> definitive after a fresh response; - load() heals a flagged, already-loaded document on 2.4.1; - refreshDocumentAuthorization() repairs on any version. package.json / package-lock.json: rdflib ^2.4.0 -> ^2.4.1, lock edited to the single dependency entry instead of regenerating it. --- package-lock.json | 8 +- package.json | 2 +- test/rdflibEditableFlagContract.test.ts | 100 ++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 test/rdflibEditableFlagContract.test.ts diff --git a/package-lock.json b/package-lock.json index 88f2070..9dad418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "jsdom": "^21.1.0", - "rdflib": "^2.4.0", + "rdflib": "^2.4.1", "solidos-toolkit": "dev", "ts-loader": "^9.6.1", "tslib": "^2.8.1", @@ -8087,9 +8087,9 @@ } }, "node_modules/rdflib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.4.0.tgz", - "integrity": "sha512-DPBFlnkA7lWgskbgyPsRxHE5S/9Ni5KHNgwzrq8CucG+TBxEHTGRSeMKjWhZlZhBhmQFu0YQGjOYyrzmkX/gwg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/rdflib/-/rdflib-2.4.1.tgz", + "integrity": "sha512-VU4bvNcbgxKTLAzj9sWcjpWjT9Yhbpkc456tGhwcn/hXU2UXf2aTCJ+nKPVnWNZYP09zGrd0sJUL6wwTvt6IZg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2f638b7..fd74174 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "eslint-plugin-import": "^2.32.0", "vitest": "^4.1.9", "jsdom": "^21.1.0", - "rdflib": "^2.4.0", + "rdflib": "^2.4.1", "ts-loader": "^9.6.1", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts new file mode 100644 index 0000000..2170b09 --- /dev/null +++ b/test/rdflibEditableFlagContract.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { fetcher, graph, lit, sym, UpdateManager } from 'rdflib' +import { refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' + +const LINK = (name: string) => sym(`http://www.w3.org/2007/ont/link#${name}`) +const HTTPH = (name: string) => sym(`http://www.w3.org/2007/ont/httph#${name}`) + +// The contract the session-transition handling relies on, proven against the +// real UpdateManager/Fetcher rather than a mock: +// 1. a response fetched anonymously answers `false` (definitively read-only); +// 2. flagging the metadata turns that into `undefined` (unknown), which is +// what sends callers to a repair; +// 3. a fresh response under the new identity answers definitively again — +// through `load()` on rdflib >= 2.4.1, and through +// `refreshDocumentAuthorization()` on any version. +describe('rdflib authorization metadata contract', () => { + it('goes from definitive to unknown when flagged, and answers again after a fresh response', () => { + const store: any = graph() + const meta = sym('urn:x-auth-test:app') + store.fetcher = { appNode: meta } + const doc = 'https://example.org/foo' + + const anonymous = { request: sym('urn:x-auth-test:req-1'), response: sym('urn:x-auth-test:res-1') } + // The fetcher stores the document URI as a string literal, not a node + // (linkeddata/rdflib.js#427); `editable()` matches it through the same + // string-to-literal coercion. + store.add(anonymous.request, LINK('requestedURI'), lit(doc), meta) + store.add(anonymous.request, LINK('response'), anonymous.response, meta) + store.add(anonymous.response, HTTPH('wac-allow'), lit('user="read"'), meta) + + const updater = new UpdateManager(store) + expect(updater.editable(doc)).toBe(false) + + // The identity changed: every recorded response is out-of-date now, so the + // answer is "unknown" — the state checkEditable()/load() repair. + updater.flagAuthorizationMetadata() + expect(updater.editable(doc)).toBeUndefined() + + // The next load records a fresh, authenticated response. + const fresh = { request: sym('urn:x-auth-test:req-2'), response: sym('urn:x-auth-test:res-2') } + store.add(fresh.request, LINK('requestedURI'), lit(doc), meta) + store.add(fresh.request, LINK('response'), fresh.response, meta) + store.add(fresh.response, HTTPH('wac-allow'), lit('user="read write"'), meta) + store.add(fresh.response, HTTPH('accept-patch'), lit('text/n3'), meta) + expect(updater.editable(doc)).toBe('N3PATCH') + }) + + it('heals a flagged, already-loaded document through load() (rdflib >= 2.4.1)', async () => { + const store: any = graph() + const doc = 'https://example.org/heal' + let calls = 0 + const fakeFetch = async (): Promise => { + calls += 1 + const headers: Record = calls === 1 + ? { 'content-type': 'text/turtle', 'wac-allow': 'user="read"' } + : { 'content-type': 'text/turtle', 'wac-allow': 'user="read write"', 'accept-patch': 'text/n3' } + return new Response('', { status: 200, headers }) + } + fetcher(store, { fetch: fakeFetch }) + store.updater = new UpdateManager(store) + + await store.fetcher.load(doc) + expect(calls).toBe(1) + expect(store.updater.editable(doc)).toBe(false) + + store.updater.flagAuthorizationMetadata() + expect(store.updater.editable(doc)).toBeUndefined() + + // 2.4.1: load() finds the recorded request (the URI is stored as a literal) + // and refetches a document whose recorded answers are all flagged, so the + // stale read-only answer is replaced by the fresh one. + await store.fetcher.load(doc) + expect(calls).toBe(2) + expect(store.updater.editable(doc)).toBe('N3PATCH') + }) + + it('repairs through refreshDocumentAuthorization() on any rdflib (the deterministic path)', async () => { + const store: any = graph() + const doc = 'https://example.org/repair' + let calls = 0 + const fakeFetch = async (): Promise => { + calls += 1 + const headers: Record = calls === 1 + ? { 'content-type': 'text/turtle', 'wac-allow': 'user="read"' } + : { 'content-type': 'text/turtle', 'wac-allow': 'user="read write"', 'accept-patch': 'text/n3' } + return new Response('', { status: 200, headers }) + } + fetcher(store, { fetch: fakeFetch }) + store.updater = new UpdateManager(store) + + await store.fetcher.load(doc) + store.updater.flagAuthorizationMetadata() + expect(store.updater.editable(doc)).toBeUndefined() + + // refresh() forces the fetch, awaiting the fetcher's completion callback, + // and only then answers from the fresh response. + await expect(refreshDocumentAuthorization(store, doc)).resolves.toBe('N3PATCH') + expect(calls).toBe(2) + }) +}) From 9adbdf6939ab975d3dbfe8e084b820ce122e90cc Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 17:52:58 +0200 Subject: [PATCH 06/11] fix(auth): apply the transition before reporting it deliver() interleaved a subscriber's own events with its transition callback, so which ran first depended on subscription order: in the app authSession subscribes before the store, and a legacy 'logout' listener could therefore read the store before it had been invalidated (measured in the e2e: a synchronous read inside the listener still saw the previous identity's write capability). Every subscriber's onTransition now runs before any subscriber's onEvent, so "the store is already invalidated when you hear the event" is a guarantee rather than an accident. Test: subscribers in the app's order observe ['transition', 'event', 'event']. --- src/authSession/identityState.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/authSession/identityState.ts b/src/authSession/identityState.ts index 250c74a..627439c 100644 --- a/src/authSession/identityState.ts +++ b/src/authSession/identityState.ts @@ -286,11 +286,17 @@ export function restoreSession (session: SessionLike | undefined): Promise { + record.subscribers.forEach(subscriber => subscriber.onTransition?.(events)) record.subscribers.forEach(subscriber => { events.forEach(event => subscriber.onEvent?.(event)) - subscriber.onTransition?.(events) }) } From 5d518c19fb5630f45fda72ce3bb6dc662ece7a24 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 18:21:45 +0200 Subject: [PATCH 07/11] refactor(auth): repair a flagged document with a load, not a forced refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rdflib 2.4.1 refetches a document whose recorded answers are all flagged (linkeddata/rdflib.js#871) — exactly the state an identity transition leaves behind — so the store side no longer forces a fetch by hand: - forceRefresh() and refreshDocumentAuthorization() are gone; repairDocument() marks the responses out-of-date (including one recorded after the transition, which the transition's own flag could not have marked) and loads, retrying under a new identity and staying unknown if it keeps moving; - a store that cannot invalidate is repaired through the same path, and ensureDocumentAuthorization() fails closed when the repair cannot be established — a store that cannot invalidate must not have its answers trusted; - the module and its tests state the requirement plainly: rdflib >= 2.4.1. Tests: the store-side suite models the flag/load contract instead of a refresh callback; the contract test asserts that a decision point repairs a flagged document through a load. --- .../flagAuthorizationOnTransitions.ts | 218 ++++++++---------- test/flagAuthorizationOnTransitions.test.ts | 152 +++++++----- test/rdflibEditableFlagContract.test.ts | 12 +- 3 files changed, 195 insertions(+), 187 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index b77e191..f4b809f 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -5,26 +5,24 @@ * `UpdateManager.editable()` is a synchronous read of the responses recorded * under `fetcher.appNode`. Those responses are not keyed by identity, so a * document fetched anonymously (before a restore completed) or under a - * previous WebID keeps answering for the old identity: a writable document - * can look read-only after login, and a read-only one can look writable after + * previous WebID keeps answering for the old identity: a writable document can + * look read-only after login, and a read-only one can look writable after * logout. * * `UpdateManager.flagAuthorizationMetadata()` marks every recorded response * out-of-date, so `editable()` answers "unknown" instead of the previous * identity's access. It is wired HERE, once, subscribed to the session's - * identity state — so it covers every transition whichever path noticed it - * (a session event, a token update, a refocus resync, a cookie probe), rather + * identity state — so it covers every transition whichever path noticed it (a + * session event, a token update, a refocus resync, a cookie probe), rather * than whichever events a call site remembered to listen for. * - * A document is repaired by a FORCE refresh — `fetcher.refresh()` sets - * `force: true, clearPreviousData: true` and records a fresh response — - * wrapped by `refreshDocumentAuthorization()` below for call sites that need - * the answer immediately. On rdflib 2.4.0 a plain load cannot repair it: - * `load()` looked the recorded request up as a NamedNode while the fetcher - * records a string literal (linkeddata/rdflib.js#427) and answered from the - * cache. From 2.4.1 `load()` matches the literal and refetches a document - * whose recorded answers are all flagged, so `checkEditable()` heals too — - * the force path stays because it is deterministic and works on both. + * The repair is then a plain `load()`: rdflib refetches a document whose + * recorded answers are ALL flagged (linkeddata/rdflib.js#871, in 2.4.1), which + * is exactly the state a transition leaves behind — and `checkEditable()` + * heals the same way. Nothing forces a fetch by hand any more. + * + * REQUIRES rdflib >= 2.4.1: on 2.4.0 `load()` answered such a document from + * the cache, so the decision points below could not re-establish an answer. */ import * as debug from '../util/debug' @@ -34,6 +32,35 @@ export type TransitionStore = { updater?: { flagAuthorizationMetadata?: () => void } } +export type AuthorizationStore = { + fetcher?: { load?: (doc: unknown) => unknown } + updater?: { + editable?: (uri: unknown) => string | boolean | undefined + flagAuthorizationMetadata?: () => void + } +} + +/** + * Marks every recorded response out-of-date. + * + * @returns whether the store could be invalidated. A store without the API, or + * one that throws, cannot be trusted afterwards — its answers stay definitive + * for the previous identity until a later transition flags successfully. + */ +function invalidate (store: AuthorizationStore): boolean { + try { + const flag = store.updater?.flagAuthorizationMetadata + if (typeof flag !== 'function') { + throw new Error('flagAuthorizationMetadata is unavailable') + } + flag.call(store.updater) + return true + } catch (error) { + debug.warn(`Could not flag authorization metadata: ${error}`) + return false + } +} + /** * Flags the store's authorization metadata on every identity transition. * @@ -44,97 +71,80 @@ export function flagAuthorizationOnSessionTransitions ( store: TransitionStore, session: SessionLike ): () => void { - const flag = (): void => { + const onTransition = (): void => { const state = storeState(store) + // Whose credentials a request would carry changed: every answer recorded + // under the previous identity is suspect from here on. state.generation += 1 - try { - const invalidate = store.updater?.flagAuthorizationMetadata - if (typeof invalidate !== 'function') { - // A store without the API cannot be invalidated — that is a failure, - // not a success: the decision points must not trust its answers. - throw new Error('flagAuthorizationMetadata is unavailable') - } - invalidate.call(store.updater) - // Every recorded response is invalidated; decision points see that as - // "unknown" and repair from there. - state.refreshRequired = false - } catch (error) { - // The store could not invalidate its metadata, so its answers stay - // definitive for the previous identity. Do not take the session - // handling down with it, but do not treat the warning as recovery - // either: record that a fresh response is required and have the - // decision points honour it (ensureDocumentAuthorization below). - state.refreshRequired = true - debug.warn(`Could not flag authorization metadata after a session transition: ${error}`) - } + state.invalidationFailed = !invalidate(store) } - // One call per applied transition (not per event: a logout that also - // replaces the identity is one invalidation). - const subscription = subscribeIdentity(session, { onTransition: () => flag() }) + const subscription = subscribeIdentity(session, { onTransition }) return () => subscription.unsubscribe() } -export type RefreshableStore = { - fetcher?: { - refresh?: (doc: unknown, callback?: (...args: unknown[]) => void) => unknown - load?: (doc: unknown) => unknown - } - updater?: { editable?: (uri: unknown) => string | boolean | undefined } -} - type StoreAuthorizationState = { /** Identity transitions observed for this store. */ generation: number - /** The store could not invalidate its metadata — do not trust its answers. */ - refreshRequired: boolean + /** + * The store could not invalidate its metadata on the last transition, so a + * definitive answer is not evidence that it is current. + */ + invalidationFailed: boolean } // Scoped per store: two `createSolidLogic` instances with different sessions -// must not overtake each other's refreshes, and a failed invalidation in one +// must not overtake each other's repairs, and a failed invalidation in one // store says nothing about another. const storeStates = new WeakMap() -const sharedState: StoreAuthorizationState = { generation: 0, refreshRequired: false } +const sharedState: StoreAuthorizationState = { generation: 0, invalidationFailed: false } function storeState (store: unknown): StoreAuthorizationState { if (store === null || typeof store !== 'object') return sharedState let state = storeStates.get(store) if (!state) { - state = { generation: 0, refreshRequired: false } + state = { generation: 0, invalidationFailed: false } storeStates.set(store, state) } return state } -/** How many times a refresh is repeated when the identity keeps changing. */ -const REFRESH_ATTEMPTS = 3 +/** How many times a repair is repeated when the identity keeps changing. */ +const REPAIR_ATTEMPTS = 3 /** - * Force-refresh one document and answer its editability under the current - * identity — the repair path for a flagged store (see above). It costs a - * round-trip; decision points that need an immediate, correct answer use it. + * Re-establishes `doc`'s answer under the current identity: mark every + * recorded response out-of-date — including one recorded AFTER the transition, + * which the transition's own flag could not have marked — and load, which + * refetches a fully flagged document. * - * The identity can change while the refresh is in flight; the response then - * belongs to the previous identity and must not answer for the current one, - * or a caller could write under the new identity on the old identity's - * authorization. Each attempt is stamped with the store's transition - * generation and repeated under the new identity when it was overtaken; if - * the identity keeps changing the answer stays "unknown" rather than stale. + * Each attempt is stamped with the store's transition generation and repeated + * when the identity changed under it: the response then belongs to the + * previous identity and must not answer for the current one, or a caller could + * write under the new identity on the old identity's authorization. If the + * identity keeps changing, the answer stays "unknown" — never stale. * - * Returns `undefined` whenever the answer cannot be established under the - * current identity: no refresh capability, a failed refresh, or an identity - * that changed throughout every attempt. A failed refresh must NOT fall back - * to the recorded answer — when the store could not be invalidated that - * answer belongs to the previous identity. + * Returns `undefined` whenever the answer cannot be established: no load + * capability, an invalidation that failed (the recorded answers are still + * definitive for the previous identity), a failed load, or an identity that + * changed throughout every attempt. */ -export async function refreshDocumentAuthorization ( - store: RefreshableStore, +async function repairDocument ( + store: AuthorizationStore, doc: unknown ): Promise { const state = storeState(store) - for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) { + const load = store.fetcher?.load + if (typeof load !== 'function') return undefined + for (let attempt = 0; attempt < REPAIR_ATTEMPTS; attempt++) { const generation = state.generation - const refreshed = await forceRefresh(store, doc) - if (!refreshed) return undefined + if (!invalidate(store)) return undefined + state.invalidationFailed = false + try { + await load.call(store.fetcher, doc) + } catch (error) { + debug.warn(`Could not reload ${String(doc)}: ${String(error)}`) + return undefined + } // The read below is synchronous, so a generation that still matches means // no transition slipped in between the response and the answer. if (generation === state.generation) { @@ -147,25 +157,25 @@ export async function refreshDocumentAuthorization ( /** * Make the store able to answer for `doc` under the current identity before * its cached triples are read or its editability gates a write. A flagged - * store answers `undefined` and is repaired here; a store whose flag FAILED - * still answers definitively for the previous identity, so it is repaired - * too (and keeps being repaired until a later transition flags successfully, - * since the failure says nothing about which other documents are stale). + * store answers `undefined` and is repaired here; a store whose invalidation + * FAILED still answers definitively for the previous identity, so it is + * repaired too (and keeps being repaired until a later transition flags + * successfully, since the failure says nothing about which other documents are + * stale). * * Returns whether the answer was established. `false` means a repair was - * needed and could not complete (no refresh capability, a failed refresh, or - * an identity that changed throughout): the caller must not consume cached - * triples from that document and must not offer a write on it. + * needed and could not complete: the caller must not consume cached triples + * from that document and must not offer a write on it. */ export async function ensureDocumentAuthorization ( - store: RefreshableStore, + store: AuthorizationStore, doc: unknown ): Promise { const state = storeState(store) - if (!state.refreshRequired && store.updater?.editable?.(doc) !== undefined) { + if (!state.invalidationFailed && store.updater?.editable?.(doc) !== undefined) { return true } - return (await refreshDocumentAuthorization(store, doc)) !== undefined + return (await repairDocument(store, doc)) !== undefined } /** @@ -174,61 +184,21 @@ export async function ensureDocumentAuthorization ( * under the previous identity can be recorded AFTER * `flagAuthorizationMetadata()` ran (the flag only marks response nodes that * already existed), which leaves a definitive-looking answer from the old - * identity behind — so an overtaken load is force-refreshed instead of being - * trusted. + * identity behind — so an overtaken load is repaired instead of being trusted. * * Returns whether the document can be consumed (see * ensureDocumentAuthorization). Load errors propagate, as a plain `load()` * would. */ export async function loadAuthorizedDocument ( - store: RefreshableStore, + store: AuthorizationStore, doc: unknown ): Promise { const state = storeState(store) const generation = state.generation await store.fetcher?.load?.(doc) if (generation !== state.generation) { - return (await refreshDocumentAuthorization(store, doc)) !== undefined + return (await repairDocument(store, doc)) !== undefined } return ensureDocumentAuthorization(store, doc) } - -/** - * rdflib's `refresh(term, callback)` is callback-based and returns void — - * it delegates to `nowOrWhenFetched(term, { force: true, clearPreviousData: - * true }, callback)` and the callback is the completion signal. Awaiting the - * call itself would read `editable()` before the fresh response is recorded, - * so wait for the callback (a promise-returning wrapper is awaited too). - * - * Resolves `true` only when a refresh actually completed; a missing refresh - * capability, a callback that reports failure, a rejected promise or a - * synchronous throw all resolve `false`, with a warning — the caller must not - * read the recorded answer in that case. - */ -async function forceRefresh (store: RefreshableStore, doc: unknown): Promise { - const refresh = store.fetcher?.refresh - if (typeof refresh !== 'function') return false - return await new Promise((resolve) => { - let settled = false - const done = (ok?: unknown, message?: unknown): void => { - if (settled) return - settled = true - if (ok === false) { - debug.warn(`Could not refresh ${String(doc)}: ${String(message)}`) - resolve(false) - } else { - resolve(true) - } - } - try { - const result = refresh.call(store.fetcher, doc, done) - if (result && typeof (result as Promise).then === 'function') { - void (result as Promise).then(() => done(), (error) => done(false, error)) - } - } catch (error) { - debug.warn(`Could not refresh ${String(doc)}: ${String(error)}`) - done(false) - } - }) -} diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index 9bacbf2..31cfc6f 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { ensureDocumentAuthorization, flagAuthorizationOnSessionTransitions, - loadAuthorizedDocument, - refreshDocumentAuthorization + loadAuthorizedDocument } from '../src/authSession/flagAuthorizationOnTransitions' import { silenceDebugMessages } from './helpers/debugger' @@ -25,28 +24,36 @@ function fakeSession (init: { isActive?: boolean, webId?: string } = {}): any { return session } -/** A store whose fetcher answers `refresh` with a completion callback. */ +/** + * A store that models the rdflib contract this module relies on: + * `flagAuthorizationMetadata()` makes every recorded answer unusable + * (`editable()` → undefined), and a `load()` of a fully flagged document + * records a fresh answer (linkeddata/rdflib.js#871). + */ function fakeStore (options: { flagFails?: boolean - editable?: string | boolean | undefined - refresh?: (doc: unknown, done: (ok?: unknown, message?: unknown) => void) => unknown + answer?: string | boolean | undefined + freshAnswer?: string | boolean | undefined load?: (doc: unknown) => unknown } = {}): any { - const editable = options.editable ?? 'N3PATCH' + let flagged = false + let answer = options.answer ?? 'SPARQL' + const freshAnswer = options.freshAnswer ?? 'SPARQL' return { updater: { flagAuthorizationMetadata: vi.fn(() => { if (options.flagFails) throw new Error('no metadata support') + flagged = true }), - editable: vi.fn(() => editable) + editable: vi.fn(() => flagged ? undefined : answer) }, fetcher: { - refresh: vi.fn((doc: unknown, done: any) => { - if (options.refresh) return options.refresh(doc, done) - done(true) - }), load: vi.fn(async (doc: unknown) => { - if (options.load) await options.load(doc) + await options.load?.(doc) + if (flagged) { + flagged = false + answer = freshAnswer + } }) } } @@ -94,6 +101,8 @@ describe('flagAuthorizationOnSessionTransitions', () => { session.fire('sessionStateChange') expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(2) + // Every recorded answer is unknown from here on. + expect(store.updater.editable(doc)).toBeUndefined() }) it('stops flagging once the subscription is released', () => { @@ -108,66 +117,99 @@ describe('flagAuthorizationOnSessionTransitions', () => { expect(store.updater.flagAuthorizationMetadata).not.toHaveBeenCalled() }) +}) - it('remembers a failed invalidation, so the decision points repair instead of trusting it', async () => { +describe('ensureDocumentAuthorization', () => { + it('trusts a definitive answer that was not invalidated, without loading', async () => { + const store = fakeStore({ answer: false }) + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) + expect(store.fetcher.load).not.toHaveBeenCalled() + }) + + it('repairs a flagged answer through a load', async () => { const session = fakeSession({ isActive: false }) - const store = fakeStore({ flagFails: true }) + const store = fakeStore() connect(store, session) session.isActive = true session.webId = 'https://alice.example/me' session.fire('sessionStateChange') - expect(store.updater.flagAuthorizationMetadata).toHaveBeenCalledTimes(1) + expect(store.updater.editable(doc)).toBeUndefined() - // The store could not be invalidated: its recorded answer belongs to the - // previous identity, so it must be refreshed even though editable() answers. await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) - expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) + expect(store.fetcher.load).toHaveBeenCalledTimes(1) + // The fresh answer is what the caller reads afterwards. + expect(store.updater.editable(doc)).toBe('SPARQL') }) - it('trusts a definitive answer that was not invalidated', async () => { - const store = fakeStore({ editable: false }) - await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) - expect(store.fetcher.refresh).not.toHaveBeenCalled() - }) -}) + it('repairs a store whose invalidation FAILED instead of trusting it', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ flagFails: true }) + connect(store, session) -describe('refreshDocumentAuthorization', () => { - it('forces the refresh and answers under the current identity', async () => { - const store = fakeStore() - await expect(refreshDocumentAuthorization(store, doc)).resolves.toBe('N3PATCH') - expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) - expect(store.updater.editable).toHaveBeenCalledTimes(1) + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(store.updater.editable(doc)).toBe('SPARQL') // still the old identity's answer + + // Cannot invalidate and cannot repair — the caller must not trust it. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) }) - it('stays unknown when the refresh fails instead of answering from the recorded copy', async () => { - const store = fakeStore({ refresh: (_doc, done) => { done(false, 'network down') } }) - await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() - expect(store.updater.editable).not.toHaveBeenCalled() + it('stays unknown when the load fails', async () => { + const session = fakeSession({ isActive: false }) + const store = fakeStore({ load: () => { throw new Error('network down') } }) + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) }) it('gives up as unknown when the identity keeps changing under it', async () => { - const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const session = fakeSession({ isActive: false }) const store = fakeStore({ - refresh: (_doc, done) => { + load: () => { // The identity moves on while the response is on its way. session.webId = session.webId === 'https://alice.example/me' ? 'https://bob.example/me' : 'https://alice.example/me' session.fire('sessionStateChange') - done(true) } }) connect(store, session) - await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() - expect(store.fetcher.refresh).toHaveBeenCalledTimes(3) - expect(store.updater.editable).not.toHaveBeenCalled() + // A first login flags the recorded answer; every repair is overtaken. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + // Never a stale answer: the module answers unknown, whatever the last + // overtaken response left behind. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) + expect(store.fetcher.load).toHaveBeenCalledTimes(3) }) - it('has no answer when the store cannot refresh at all', async () => { - const store: any = { updater: { editable: vi.fn(() => 'N3PATCH') } } - await expect(refreshDocumentAuthorization(store, doc)).resolves.toBeUndefined() + it('has no answer when the store cannot load at all', async () => { + const session = fakeSession({ isActive: false }) + // No fetcher, and flagging fails: the recorded answer stays definitive for + // the previous identity and there is nothing to reload it with. + const store: any = { + updater: { + editable: vi.fn(() => 'SPARQL'), + flagAuthorizationMetadata: vi.fn(() => { throw new Error('cannot invalidate') }) + } + } + connect(store, session) + + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + + // The caller must not trust it, and must not act on a stale answer either. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(false) }) }) @@ -176,7 +218,6 @@ describe('loadAuthorizedDocument', () => { const store = fakeStore() await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) expect(store.fetcher.load).toHaveBeenCalledTimes(1) - expect(store.fetcher.refresh).not.toHaveBeenCalled() }) it('repairs a load that a transition overtook', async () => { @@ -193,25 +234,20 @@ describe('loadAuthorizedDocument', () => { connect(store, session) await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(true) - expect(store.fetcher.refresh).toHaveBeenCalledTimes(1) + expect(store.fetcher.load).toHaveBeenCalledTimes(2) + expect(store.updater.editable(doc)).toBe('SPARQL') }) it('reports false when the repair cannot be established', async () => { const session = fakeSession({ isActive: false }) - const store: any = { - updater: { - flagAuthorizationMetadata: vi.fn(), - editable: vi.fn(() => 'N3PATCH') - }, - // no fetcher.refresh: nothing can be re-answered - fetcher: { - load: vi.fn(async () => { - session.isActive = true - session.webId = 'https://alice.example/me' - session.fire('sessionStateChange') - }) + const store = fakeStore({ + flagFails: true, + load: () => { + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') } - } + }) connect(store, session) await expect(loadAuthorizedDocument(store, doc)).resolves.toBe(false) diff --git a/test/rdflibEditableFlagContract.test.ts b/test/rdflibEditableFlagContract.test.ts index 2170b09..b1b52ef 100644 --- a/test/rdflibEditableFlagContract.test.ts +++ b/test/rdflibEditableFlagContract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { fetcher, graph, lit, sym, UpdateManager } from 'rdflib' -import { refreshDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' +import { ensureDocumentAuthorization } from '../src/authSession/flagAuthorizationOnTransitions' const LINK = (name: string) => sym(`http://www.w3.org/2007/ont/link#${name}`) const HTTPH = (name: string) => sym(`http://www.w3.org/2007/ont/httph#${name}`) @@ -74,7 +74,7 @@ describe('rdflib authorization metadata contract', () => { expect(store.updater.editable(doc)).toBe('N3PATCH') }) - it('repairs through refreshDocumentAuthorization() on any rdflib (the deterministic path)', async () => { + it('lets a decision point repair a flagged document through a load', async () => { const store: any = graph() const doc = 'https://example.org/repair' let calls = 0 @@ -92,9 +92,11 @@ describe('rdflib authorization metadata contract', () => { store.updater.flagAuthorizationMetadata() expect(store.updater.editable(doc)).toBeUndefined() - // refresh() forces the fetch, awaiting the fetcher's completion callback, - // and only then answers from the fresh response. - await expect(refreshDocumentAuthorization(store, doc)).resolves.toBe('N3PATCH') + // ensureDocumentAuthorization() re-flags and loads; rdflib refetches a + // fully flagged document, so the decision point answers from the fresh + // response instead of the previous identity's. + await expect(ensureDocumentAuthorization(store, doc)).resolves.toBe(true) expect(calls).toBe(2) + expect(store.updater.editable(doc)).toBe('N3PATCH') }) }) From f877a54aee65245acb35f01055b53469f4f68ae2 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 18:47:36 +0200 Subject: [PATCH 08/11] fix(auth): fail a load with no loader and release the store subscription loadAuthorizedDocument() answers whether a document can be consumed; on a store without fetcher.load it fell through to ensureDocumentAuthorization() and could answer "yes" for a document it never loaded. It now fails like a plain load() would. createSolidLogic() keeps the unsubscribe that flagAuthorizationOnSessionTransitions() returns and calls it from authn.dispose(), so replacing a SolidLogic instance no longer leaves the old store subscribed to the session (and retained by it). --- .../flagAuthorizationOnTransitions.ts | 8 +++- src/logic/solidLogic.ts | 12 +++++- test/flagAuthorizationOnTransitions.test.ts | 7 ++++ test/solidLogic.test.ts | 42 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 test/solidLogic.test.ts diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index f4b809f..3fbea60 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -196,7 +196,13 @@ export async function loadAuthorizedDocument ( ): Promise { const state = storeState(store) const generation = state.generation - await store.fetcher?.load?.(doc) + const load = store.fetcher?.load + if (typeof load !== 'function') { + // A plain load() would fail on a store with no fetcher: this call loads + // `doc`, so it must not answer "consumed" after skipping the load. + throw new Error('fetcher.load is unavailable') + } + await load.call(store.fetcher, doc) if (generation !== state.generation) { return (await repairDocument(store, doc)) !== undefined } diff --git a/src/logic/solidLogic.ts b/src/logic/solidLogic.ts index d653233..fd45df5 100644 --- a/src/logic/solidLogic.ts +++ b/src/logic/solidLogic.ts @@ -30,10 +30,18 @@ export function createSolidLogic(specialFetch: { fetch: (url: any, requestInit: // response out-of-date so editability answers "unknown" instead of the // previous identity's access. Decision points repair with // ensureDocumentAuthorization() (see flagAuthorizationOnTransitions.ts). - // The subscription lives as long as the identity state's, i.e. the session's. - flagAuthorizationOnSessionTransitions(store, session) + const unsubscribeAuthorization = flagAuthorizationOnSessionTransitions(store, session) const authn: AuthnLogic = new SolidAuthnLogic(session) + + // The subscription is released with the auth logic: both belong to this + // instance's lifetime, so replacing a SolidLogic instance does not leave + // the old store subscribed to the session. + const disposeAuthn = authn.dispose?.bind(authn) + authn.dispose = (): void => { + unsubscribeAuthorization() + disposeAuthn?.() + } const acl = createAclLogic(store) const containerLogic = createContainerLogic(store) diff --git a/test/flagAuthorizationOnTransitions.test.ts b/test/flagAuthorizationOnTransitions.test.ts index 31cfc6f..c81626e 100644 --- a/test/flagAuthorizationOnTransitions.test.ts +++ b/test/flagAuthorizationOnTransitions.test.ts @@ -220,6 +220,13 @@ describe('loadAuthorizedDocument', () => { expect(store.fetcher.load).toHaveBeenCalledTimes(1) }) + it('refuses to report success when the store cannot load at all', async () => { + // No fetcher to load with: the call fails like a plain load() would, + // instead of answering "consumed" for a document it never loaded. + const store: any = { updater: { editable: vi.fn(() => 'SPARQL') } } + await expect(loadAuthorizedDocument(store, doc)).rejects.toThrow('fetcher.load is unavailable') + }) + it('repairs a load that a transition overtook', async () => { const session = fakeSession({ isActive: false }) const store = fakeStore({ diff --git a/test/solidLogic.test.ts b/test/solidLogic.test.ts new file mode 100644 index 0000000..4e245e2 --- /dev/null +++ b/test/solidLogic.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSolidLogic } from '../src/logic/solidLogic' +import { silenceDebugMessages } from './helpers/debugger' + +silenceDebugMessages() + +type Listener = () => void + +/** A fake uvdsl-style session: the properties the identity state reads. */ +function fakeSession (): any { + const listeners = new Map>() + return { + isActive: false, + webId: undefined as string | undefined, + addEventListener (type: string, listener: Listener) { + if (!listeners.has(type)) listeners.set(type, new Set()) + listeners.get(type)!.add(listener) + }, + fire (type: string): void { listeners.get(type)?.forEach(listener => listener()) } + } +} + +describe('createSolidLogic', () => { + it('releases the store invalidation subscription with the auth logic', () => { + const session = fakeSession() + const logic = createSolidLogic({ fetch: vi.fn() }, session) + const flag = vi.spyOn(logic.store.updater!, 'flagAuthorizationMetadata') + + // A login flags every recorded response: the store is subscribed. + session.isActive = true + session.webId = 'https://alice.example/me' + session.fire('sessionStateChange') + expect(flag).toHaveBeenCalledTimes(1) + + // Disposing the auth logic releases the store's subscription as well. + logic.authn.dispose?.() + + session.webId = 'https://bob.example/me' + session.fire('sessionStateChange') + expect(flag).toHaveBeenCalledTimes(1) + }) +}) From dbfcf108b3d8a11ed667f5a6ae8226c659be7f76 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 19:00:23 +0200 Subject: [PATCH 09/11] fix(auth): report token updates that fail, and take the newest resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit watchTokenUpdates() only compared the identity when setTokenDetails() resolved, so an update that applied the new identity and then rejected (a failed persistence, say) left the transition unseen — the one thing the wrapper exists to catch. The comparison now runs on success, on rejection and on a synchronous throw; the failure itself is rethrown untouched. resyncActionOf() promised the newest resync action but returned the first subscriber that had one, i.e. the oldest. It keeps the last match instead, so a second provider cannot make refocus behaviour depend on subscription order. --- src/authSession/identityState.ts | 31 ++++++++++++++++++++++------- test/identityState.test.ts | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/authSession/identityState.ts b/src/authSession/identityState.ts index 627439c..bd14864 100644 --- a/src/authSession/identityState.ts +++ b/src/authSession/identityState.ts @@ -445,10 +445,12 @@ async function resyncThenNote (record: Record): Promise { * session. */ function resyncActionOf (record: Record): (() => unknown) | undefined { + // Subscribers iterate in insertion order, so the last match is the newest. + let newest: (() => unknown) | undefined for (const subscriber of record.subscribers) { - if (subscriber.resync) return subscriber.resync + if (subscriber.resync) newest = subscriber.resync } - return undefined + return newest } // uvdsl's session announces only changes of `isActive`; a WebID can change @@ -471,12 +473,27 @@ function watchTokenUpdates (record: Record): void { const after = snapshotOf(record) return after.webId !== before.webId || after.isActive !== before.isActive } - const result = original.apply(session, args) + // Whatever the outcome — a token update can apply the new identity and + // then fail (a failed persistence, for one) — the identity around the call + // is what matters, so the change is reported and the failure passes on. + let result: unknown + try { + result = original.apply(session, args) + } catch (error) { + if (changed()) note(record) + throw error + } if (result && typeof (result as Promise).then === 'function') { - return (result as Promise).then((value) => { - if (changed()) note(record) - return value - }) + return (result as Promise).then( + (value: unknown) => { + if (changed()) note(record) + return value + }, + (error: unknown) => { + if (changed()) note(record) + throw error + } + ) } if (changed()) note(record) return result diff --git a/test/identityState.test.ts b/test/identityState.test.ts index 6389605..248c247 100644 --- a/test/identityState.test.ts +++ b/test/identityState.test.ts @@ -145,6 +145,21 @@ describe('identityState — session transitions', () => { expect(original).toHaveBeenCalledTimes(1) expect(events).toEqual(['sessionChange', 'identityReplaced']) }) + + it('reports an identity a rejected token update still applied', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + // The update applies the new identity and then fails (persistence). + session.setTokenDetails = vi.fn(async () => { + session.webId = 'https://bob.example/me' + throw new Error('persist failed') + }) + const { events, emit } = collect() + subscribeIdentity(session, { onEvent: emit }) + + await expect(session.setTokenDetails('token')).rejects.toThrow('persist failed') + await flush() + expect(events).toEqual(['sessionChange', 'identityReplaced']) + }) }) describe('identityState — refocus resync', () => { @@ -198,6 +213,25 @@ describe('identityState — refocus resync', () => { await flush() }) + it('takes the newest resync action when several subscribers provide one', async () => { + const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) + const calls: string[] = [] + subscribeIdentity(session, { + resync: async () => { + calls.push('older') + } + }) + subscribeIdentity(session, { + resync: async () => { + calls.push('newer') + } + }) + + document.dispatchEvent(new Event('visibilitychange')) + await flush() + expect(calls).toEqual(['newer']) + }) + it('drops an answer that belongs to an identity the session has left', async () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() From bb5575b153e0f3cb6a0cd8142458a4549a5c91a0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 19:11:25 +0200 Subject: [PATCH 10/11] fix(auth): format the caught value; release identity subscriptions in tests invalidate() interpolated the caught value directly, which reads "[object Object]" for a non-Error throw; String(error) matches the reload warning in the same module. The identityState tests now subscribe through a helper that keeps every handle and releases it in afterEach: a first subscription attaches a visibilitychange listener to the document, and leaving those behind lets a later test's refocus run resyncs (and cookie probes) from a finished one. --- .../flagAuthorizationOnTransitions.ts | 2 +- test/identityState.test.ts | 56 +++++++++++++------ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/authSession/flagAuthorizationOnTransitions.ts b/src/authSession/flagAuthorizationOnTransitions.ts index 3fbea60..1cbfa41 100644 --- a/src/authSession/flagAuthorizationOnTransitions.ts +++ b/src/authSession/flagAuthorizationOnTransitions.ts @@ -56,7 +56,7 @@ function invalidate (store: AuthorizationStore): boolean { flag.call(store.updater) return true } catch (error) { - debug.warn(`Could not flag authorization metadata: ${error}`) + debug.warn(`Could not flag authorization metadata: ${String(error)}`) return false } } diff --git a/test/identityState.test.ts b/test/identityState.test.ts index 248c247..10a05b1 100644 --- a/test/identityState.test.ts +++ b/test/identityState.test.ts @@ -47,6 +47,28 @@ const collect = (): { events: IdentityEvent[], emit: (event: IdentityEvent) => v return { events, emit: (event: IdentityEvent) => { events.push(event) } } } +const subscriptions: Array<{ unsubscribe: () => void }> = [] + +/** + * Subscribes and remembers the handle: a first subscription attaches a + * `visibilitychange` listener to the document, and the tests release theirs so + * a later test's refocus cannot run resyncs or probes from a finished one. + */ +const subscribe = ( + session: any, + options: Parameters[1] = {} +): ReturnType => { + const subscription = subscribeIdentity(session, options) + subscriptions.push(subscription) + return subscription +} + +// Release whatever each test subscribed, whichever assertions it made. +afterEach(() => { + subscriptions.forEach(subscription => subscription.unsubscribe()) + subscriptions.length = 0 +}) + describe('identityState — predicates', () => { it('treats isActive as authoritative and the WebID as the legacy fallback', () => { expect(sessionIsActive({ isActive: true })).toBe(true) @@ -89,7 +111,7 @@ describe('identityState — session transitions', () => { it('reports a login once, and nothing when the identity does not move', () => { const session = fakeSession({ isActive: false }) const { events, emit } = collect() - subscribeIdentity(session, { onEvent: emit }) + subscribe(session, { onEvent: emit }) session.isActive = true session.webId = 'https://alice.example/me' @@ -106,7 +128,7 @@ describe('identityState — session transitions', () => { it('reports a logout, and one replacement for the identity that was active', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() - subscribeIdentity(session, { onEvent: emit }) + subscribe(session, { onEvent: emit }) session.isActive = false session.fire('sessionStateChange') @@ -123,7 +145,7 @@ describe('identityState — session transitions', () => { it('reports an identity change while the session stays active', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() - subscribeIdentity(session, { onEvent: emit }) + subscribe(session, { onEvent: emit }) session.webId = 'https://bob.example/me' session.fire('sessionStateChange') @@ -137,9 +159,9 @@ describe('identityState — session transitions', () => { const original = vi.fn(async () => { session.webId = 'https://bob.example/me' }) session.setTokenDetails = original const { events, emit } = collect() - subscribeIdentity(session, { onEvent: emit }) + subscribe(session, { onEvent: emit }) - subscribeIdentity(session, {}) // second subscription must not wrap again + subscribe(session, {}) // second subscription must not wrap again await session.setTokenDetails('token') await flush() expect(original).toHaveBeenCalledTimes(1) @@ -154,7 +176,7 @@ describe('identityState — session transitions', () => { throw new Error('persist failed') }) const { events, emit } = collect() - subscribeIdentity(session, { onEvent: emit }) + subscribe(session, { onEvent: emit }) await expect(session.setTokenDetails('token')).rejects.toThrow('persist failed') await flush() @@ -175,7 +197,7 @@ describe('identityState — refocus resync', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() let restores = 0 - subscribeIdentity(session, { + subscribe(session, { onEvent: emit, resync: async () => { restores += 1 @@ -198,7 +220,7 @@ describe('identityState — refocus resync', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) let resolveRestore: (value: unknown) => void = () => undefined let restores = 0 - subscribeIdentity(session, { + subscribe(session, { resync: () => { restores += 1 return new Promise(resolve => { resolveRestore = resolve }) @@ -216,12 +238,12 @@ describe('identityState — refocus resync', () => { it('takes the newest resync action when several subscribers provide one', async () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const calls: string[] = [] - subscribeIdentity(session, { + subscribe(session, { resync: async () => { calls.push('older') } }) - subscribeIdentity(session, { + subscribe(session, { resync: async () => { calls.push('newer') } @@ -236,7 +258,7 @@ describe('identityState — refocus resync', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() let resolveRestore: (value: unknown) => void = () => undefined - subscribeIdentity(session, { + subscribe(session, { onEvent: emit, resync: () => new Promise(resolve => { resolveRestore = resolve }) }) @@ -260,7 +282,7 @@ describe('identityState — refocus resync', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() let resolveRestore: (value: unknown) => void = () => undefined - subscribeIdentity(session, { + subscribe(session, { onEvent: emit, resync: () => new Promise(resolve => { resolveRestore = resolve }) }) @@ -285,7 +307,7 @@ describe('identityState — cookie identity', () => { it('adopts a cookie identity silently as a replacement source, and reports its loss', () => { const session = fakeSession({ isActive: false }) const { events, emit } = collect() - const subscription = subscribeIdentity(session, { onEvent: emit }) + const subscription = subscribe(session, { onEvent: emit }) subscription.reportCookieIdentity('https://cookie.example/profile/card#me') expect(events).toEqual(['sessionChange']) @@ -304,7 +326,7 @@ describe('identityState — cookie identity', () => { it('does not replace the identity while the session owns it', () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const { events, emit } = collect() - const subscription = subscribeIdentity(session, { onEvent: emit }) + const subscription = subscribe(session, { onEvent: emit }) subscription.reportCookieIdentity('https://cookie.example/profile/card#me') expect(events).toEqual([]) @@ -313,7 +335,7 @@ describe('identityState — cookie identity', () => { it('forgets a remembered cookie identity once the session owns one again', () => { const session = fakeSession({ isActive: false }) - const subscription = subscribeIdentity(session, {}) + const subscription = subscribe(session, {}) subscription.reportCookieIdentity('https://cookie.example/profile/card#me') expect(effectiveIdentity(session).source).toBe('cookie') @@ -330,7 +352,7 @@ describe('identityState — cookie identity', () => { it('ignores a probe that answers after its subscription was released', () => { const session = fakeSession({ isActive: false }) const { events, emit } = collect() - const subscription = subscribeIdentity(session, { onEvent: emit }) + const subscription = subscribe(session, { onEvent: emit }) subscription.unsubscribe() subscription.reportCookieIdentity('https://cookie.example/profile/card#me') @@ -341,7 +363,7 @@ describe('identityState — cookie identity', () => { it('forwards a refocus to the subscribers and removes the listener with the last one', async () => { const session = fakeSession({ isActive: true, webId: 'https://alice.example/me' }) const onRefocus = vi.fn() - const first = subscribeIdentity(session, { onRefocus }) + const first = subscribe(session, { onRefocus }) document.dispatchEvent(new Event('visibilitychange')) expect(onRefocus).toHaveBeenCalledTimes(1) From 612a99d2249ad65ef8ff643099201955018ad3df Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 20 Sep 2026 19:14:34 +0200 Subject: [PATCH 11/11] 6.0.0-2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9dad418..2d25212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-logic", - "version": "6.0.0-0", + "version": "6.0.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-logic", - "version": "6.0.0-0", + "version": "6.0.0-2", "license": "MIT", "dependencies": { "@uvdsl/solid-oidc-client-browser": "^0.2.3", diff --git a/package.json b/package.json index fd74174..84232c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-logic", - "version": "6.0.0-1", + "version": "6.0.0-2", "description": "Core business logic of SolidOS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js",