Detect a revoked or stale app registration and leave the broken connected view

While a session exists, the app probes getAppStatus() on load and on focus/visibility, at most once a minute (a trigger inside the minute is deferred, not dropped), failing open on errors. installed:false or upgradeNeeded:true re-runs registerApp, whose consent redirect is the recovery; meanwhile the app view is replaced by a renewal screen, and an unrecoverable registration lands on the picker with a notice. bun run test:e2e-revocation proves both sides on the live stack: a current registration stays usable, a revoked one leaves the connected state and its writes no longer land.
This commit is contained in:
Sylvain Duchesne
2026-10-12 16:07:14 +02:00
parent e21c408dc1
commit 4fc856fa7e
8 changed files with 1167 additions and 8 deletions
+127 -3
View File
@@ -10,6 +10,10 @@
* `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
* establishes is published to `utils/currentSession` — the module store the
* data provider's dispatch reads, independent of where each mounts in the tree.
* While signed in, the app-status probe (`auth/appStatusProbe`) re-checks the
* registration on load and on focus/visibility: a revoked or stale app re-runs
* the provider's `registerApp` (its consent redirect is the recovery) instead of
* staying in a "connected" view whose writes cannot land.
*
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
@@ -24,7 +28,7 @@
* remains reachable.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ensureIdentity } from '@ng-eventually/polyfill';
import { startNgSession } from '../shared/utils/ngSession';
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
@@ -39,8 +43,19 @@ import {
safeRedirectPath,
} from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
import { recoverRegistration, resolveStaleRegistration, type StaleReason } from './auth/appStatusProbe';
import { useAppStatusProbe } from './auth/useAppStatusProbe';
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
/**
* `reauthorizing`: the app-status probe found the registration revoked or stale
* and `registerApp` is sending the browser to the consent screen — the app view
* is taken down rather than left looking connected.
*/
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error';
/** Shown on the picker when a revoked/stale registration could not be re-run. */
const REGISTRATION_LOST_NOTICE =
'Festipod n’est plus autorisé sur votre Pod. Reconnectez-vous pour l’autoriser à nouveau.';
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
@@ -86,6 +101,22 @@ export function AuthGate({ children }: { children: ReactNode }) {
// ActivityPods flow state.
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
const [apError, setApError] = useState<string | null>(null);
// Why the picker is back after a session existed (registration lost).
const [apNotice, setApNotice] = useState<string | null>(null);
// Set when the app-status probe took the connected view down: the consent
// redirect is under way ('reauthorizing'), or the registration is gone and
// could not be re-run ('registration-lost'). The provider's token is still
// stored (logout is not ours to trigger here), so without this any later
// route change — a Back, a link — would re-derive "authenticated" from that
// token and bring back a view that can neither read nor write. Lives for the
// page: the consent and the picker's sign-in both leave it for the pod, and a
// reload probes afresh.
const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null);
// One recovery at a time: the probe already serializes its own, this also
// covers repeated clicks on the reauthorizing screen's retry (each would
// start its own consent redirect). A `registerApp` that never settles keeps
// it set — the same limit as a hung probe run; a reload clears it.
const recoveringRef = useRef(false);
const apAuthProvider = useMemo(() => {
if (!activityPodsEnabled) return undefined;
@@ -188,10 +219,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
}
const session = provider.getSession();
if (session) {
const blocked = blockedRef.current;
if (session && !blocked) {
setCurrentPrincipal(session.webId);
setCurrentSession({ webId: session.webId, token: session.token });
if (!cancelled) setApState('authenticated');
} else if (session && blocked?.kind === 'reauthorizing') {
if (!cancelled) setApState('reauthorizing');
} else {
setCurrentSession(undefined);
if (!cancelled) setApState('unauthenticated');
@@ -209,6 +243,71 @@ export function AuthGate({ children }: { children: ReactNode }) {
return () => { cancelled = true; };
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
// A revoked or stale registration: re-run the provider's registration flow.
// Its consent redirect IS the recovery; a resolved URI means the registration
// is current after all and the app stays as it is. When it fails, what
// happens depends on what was found: a REVOKED registration (nothing can be
// read or written) lands on the picker with a notice; a registration that
// only needs an upgrade stays connected and is retried by the next probe.
const handleStaleRegistration = useCallback(async (reason: StaleReason) => {
const provider = apAuthProvider;
if (!provider || recoveringRef.current) return;
recoveringRef.current = true;
let outcome: Awaited<ReturnType<typeof recoverRegistration>>;
try {
outcome = await recoverRegistration({
getWebId: () => provider.getSession()?.webId,
registerApp: (webId) => provider.registerApp(webId),
});
} finally {
recoveringRef.current = false;
}
const resolution = resolveStaleRegistration(reason, outcome);
console.info(`[AppStatus] recovery (${reason}) → ${outcome.kind} → ${resolution}`);
if (outcome.kind === 'failed') console.error('[AppStatus] registerApp failed:', outcome.detail);
switch (resolution) {
case 'stay-connected': {
// From the probe this changes nothing; from the reauthorizing screen's
// retry (a consent given meanwhile) it brings the app back — with the
// session published again, or the data layer would serve demo data
// behind a connected-looking app.
const session = provider.getSession();
if (!session) {
blockedRef.current = null;
setCurrentSession(undefined);
setApState('unauthenticated');
return;
}
blockedRef.current = null;
setCurrentPrincipal(session.webId);
setCurrentSession({ webId: session.webId, token: session.token });
setApState('authenticated');
return;
}
case 'reauthorizing':
blockedRef.current = { kind: 'reauthorizing', reason };
setApState('reauthorizing');
return;
case 'signed-out':
blockedRef.current = null;
setCurrentSession(undefined);
setApState('unauthenticated');
return;
case 'registration-lost':
blockedRef.current = { kind: 'registration-lost' };
setCurrentSession(undefined);
setApNotice(REGISTRATION_LOST_NOTICE);
setApState('unauthenticated');
return;
}
}, [apAuthProvider]);
useAppStatusProbe({
provider: apAuthProvider,
active: activityPodsEnabled && !demoMode && apState === 'authenticated',
onStale: handleStaleRegistration,
});
// Once identified, leave the welcome screen for the app home.
useEffect(() => {
if (apState === 'authenticated' && route.page === 'welcome') {
@@ -265,6 +364,30 @@ export function AuthGate({ children }: { children: ReactNode }) {
if (apState === 'checking') {
return null;
}
if (apState === 'reauthorizing') {
return (
<div id="auth-reauthorizing" role="status" className="app-card" style={{ margin: '2rem 1rem' }}>
<h1 className="app-title">Autorisation à renouveler</h1>
<p className="app-text">
Festipod n’est plus autorisé sur votre Pod, ou son autorisation doit être
mise à jour. Redirection vers votre Pod pour la renouveler…
</p>
{/* The redirect can be interrupted (Back, a page restored from the
browser's cache): this screen must never be a dead end. */}
<button
type="button"
className="app-btn"
onClick={() => {
const blocked = blockedRef.current;
void handleStaleRegistration(blocked?.kind === 'reauthorizing' ? blocked.reason : 'not-installed');
}}
style={{ marginTop: 16 }}
>
Continuer vers mon Pod
</button>
</div>
);
}
if (apState === 'unauthenticated') {
// Seed the post-login destination: an explicit (validated) `?redirect=`
// wins; otherwise a deep link the user opened lands them back on that page
@@ -280,6 +403,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
<PodProviderPicker
authProvider={apAuthProvider!}
redirect={loginRedirect}
notice={apNotice ?? undefined}
onDemo={() => {
setDemoMode(true);
navigate('/home');
+14 -1
View File
@@ -11,6 +11,8 @@ interface PodProviderPickerProps {
* so the picker never reads the raw URL itself.
*/
redirect: string;
/** Why the user is back on the picker (e.g. the app's registration was revoked). */
notice?: string;
onDemo?: () => void;
}
@@ -21,7 +23,7 @@ interface PodProviderPickerProps {
* offered as a one-click option. The user can also type any Solid Pod provider
* URL. A "continue offline" button keeps the local demo fallback reachable.
*/
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
export function PodProviderPicker({ authProvider, redirect, notice, onDemo }: PodProviderPickerProps) {
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
const [customUrl, setCustomUrl] = useState('');
@@ -68,6 +70,17 @@ export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProvide
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
</Text>
{notice && (
<p
role="status"
data-testid="auth-picker-notice"
className="app-text"
style={{ textAlign: 'center', fontSize: 14, marginBottom: 24, color: '#b45309' }}
>
{notice}
</p>
)}
{defaultProviderHost && (
<Button
variant="primary"
+319
View File
@@ -0,0 +1,319 @@
import { describe, expect, test } from 'bun:test';
import {
MIN_PROBE_INTERVAL_MS,
classifyAppStatus,
createAppStatusProbe,
recoverRegistration,
resolveStaleRegistration,
type AppStatusProbeDeps,
type StaleReason,
} from './appStatusProbe';
// Bodies as the local pod (v2.3.12) returns them from /.well-known/app-status to
// the app's session token (recorded by scripts/e2e-revocation.ts): current, and
// after the registration was removed. UPGRADE is not observed — it is the shape
// the contract's re-baseline names (`upgradeNeeded: true`).
const CURRENT = { onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [] };
const REVOKED = { onlineBackend: true, installed: false, upgradeNeeded: false };
const UPGRADE = { onlineBackend: true, installed: true, upgradeNeeded: true, webhookChannels: [] };
/** A probe over a scripted status source, a manual clock, manual timers and a captured log. */
function harness(statuses: Array<unknown | (() => Promise<unknown>)>, opts: { session?: () => boolean } = {}) {
let clock = 1_000_000;
let nextHandle = 1;
const timers = new Map<number, { fn: () => void; due: number }>();
const calls: number[] = [];
const stale: StaleReason[] = [];
const logs: string[] = [];
const queue = [...statuses];
const deps: AppStatusProbeDeps = {
hasSession: opts.session ?? (() => true),
getAppStatus: () => {
calls.push(clock);
const next = queue.shift();
return typeof next === 'function' ? (next as () => Promise<unknown>)() : Promise.resolve(next);
},
onStale: (reason) => { stale.push(reason); },
now: () => clock,
log: (m) => { logs.push(m); },
setTimer: (fn, ms) => { const h = nextHandle++; timers.set(h, { fn, due: clock + ms }); return h; },
clearTimer: (h) => { timers.delete(h as number); },
};
return {
probe: createAppStatusProbe(deps),
calls,
stale,
logs,
timers,
/** Move the clock and fire every timer that came due, as the event loop would. */
advance: async (ms: number) => {
clock += ms;
for (const [h, t] of [...timers]) {
if (t.due <= clock) { timers.delete(h); t.fn(); }
}
// let the fired runs settle
for (let i = 0; i < 5; i++) await Promise.resolve();
},
};
}
describe('classifyAppStatus', () => {
test('installed and no upgrade is current', () => {
expect(classifyAppStatus(CURRENT)).toEqual({ kind: 'current' });
});
test('not installed (what a revocation leaves) is stale even though upgradeNeeded is false', () => {
expect(classifyAppStatus(REVOKED)).toEqual({ kind: 'stale', reason: 'not-installed' });
});
test('upgradeNeeded is stale', () => {
expect(classifyAppStatus(UPGRADE)).toEqual({ kind: 'stale', reason: 'upgrade-needed' });
});
test('not installed wins over upgradeNeeded', () => {
expect(classifyAppStatus({ installed: false, upgradeNeeded: true })).toEqual({ kind: 'stale', reason: 'not-installed' });
});
test('a body that decides nothing is undecided, not stale', () => {
expect(classifyAppStatus({ upgradeNeeded: false }).kind).toBe('undecided');
expect(classifyAppStatus({ installed: 'yes', upgradeNeeded: false }).kind).toBe('undecided');
expect(classifyAppStatus(null).kind).toBe('undecided');
expect(classifyAppStatus('ok').kind).toBe('undecided');
expect(classifyAppStatus([CURRENT]).kind).toBe('undecided');
});
});
describe('createAppStatusProbe — throttle', () => {
test('the interval is never below a minute', () => {
expect(MIN_PROBE_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
});
test('a trigger less than a minute after the previous run makes no request now', async () => {
const h = harness([CURRENT, CURRENT]);
expect(await h.probe.run('load')).toEqual({ kind: 'current' });
await h.advance(MIN_PROBE_INTERVAL_MS - 1);
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 1 });
expect(h.calls).toHaveLength(1);
});
test('a throttled trigger is deferred to the end of the minute, not dropped', async () => {
// The user revokes in the pod's tab and comes back 40 s after the load probe.
const h = harness([CURRENT, REVOKED]);
await h.probe.run('load');
await h.advance(40_000);
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 20_000 });
await h.advance(19_999);
expect(h.calls).toHaveLength(1);
await h.advance(1);
expect(h.calls).toHaveLength(2);
expect(h.stale).toEqual(['not-installed']);
expect(h.logs.some((l) => l.includes('probe (focus) → stale (not-installed)'))).toBe(true);
});
test('at most one deferred run is pending, whatever the number of triggers', async () => {
const h = harness([CURRENT, CURRENT, CURRENT]);
await h.probe.run('load');
await h.advance(10_000);
await h.probe.run('focus');
expect(await h.probe.run('visibility')).toEqual({ kind: 'skipped', why: 'throttled' });
await h.probe.run('focus');
expect(h.timers.size).toBe(1);
await h.advance(MIN_PROBE_INTERVAL_MS);
expect(h.calls).toHaveLength(2);
expect(h.timers.size).toBe(0);
});
test('two runs never start less than a minute apart, deferred ones included', async () => {
const h = harness([CURRENT, CURRENT, CURRENT]);
await h.probe.run('load');
await h.advance(30_000);
await h.probe.run('focus'); // deferred to +60 s
await h.advance(30_000); // deferred run starts here
await h.advance(1_000);
await h.probe.run('visibility'); // deferred to +120 s
await h.advance(58_999);
expect(h.calls).toHaveLength(2);
await h.advance(1);
expect(h.calls).toHaveLength(3);
const gaps = h.calls.slice(1).map((t, i) => t - h.calls[i]!);
expect(gaps.every((g) => g >= MIN_PROBE_INTERVAL_MS)).toBe(true);
});
test('cancelPending drops a deferred trigger — nothing probes once the view is gone', async () => {
const h = harness([CURRENT, CURRENT]);
await h.probe.run('load');
await h.advance(5_000);
await h.probe.run('focus');
h.probe.cancelPending();
expect(h.timers.size).toBe(0);
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
expect(h.calls).toHaveLength(1);
});
test('a deferred run whose session has ended requests nothing', async () => {
let session = true;
const h = harness([CURRENT, CURRENT], { session: () => session });
await h.probe.run('load');
await h.advance(5_000);
await h.probe.run('focus');
session = false;
await h.advance(MIN_PROBE_INTERVAL_MS);
expect(h.calls).toHaveLength(1);
});
test('a run a full minute later probes again', async () => {
const h = harness([CURRENT, REVOKED]);
await h.probe.run('load');
await h.advance(MIN_PROBE_INTERVAL_MS);
expect(await h.probe.run('focus')).toEqual({ kind: 'stale', reason: 'not-installed' });
expect(h.calls).toHaveLength(2);
});
test('a run while one is in flight is skipped', async () => {
let release: (v: unknown) => void = () => {};
const h = harness([() => new Promise((r) => { release = r; })]);
const first = h.probe.run('load');
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
release(CURRENT);
expect(await first).toEqual({ kind: 'current' });
expect(h.calls).toHaveLength(1);
});
test('without a session nothing is requested and the minute is not consumed', async () => {
const h = harness([CURRENT], { session: () => false });
expect(await h.probe.run('load')).toEqual({ kind: 'skipped', why: 'no-session' });
expect(h.calls).toHaveLength(0);
});
});
describe('createAppStatusProbe — fails open', () => {
test('a network error is logged and nothing else happens', async () => {
const h = harness([() => Promise.reject(new TypeError('Failed to fetch'))]);
const outcome = await h.probe.run('load');
expect(outcome.kind).toBe('failed-open');
expect(h.stale).toEqual([]);
expect(h.logs.some((l) => l.includes('failing open') && l.includes('Failed to fetch'))).toBe(true);
});
test('a 5xx (the provider throws with status attached) fails open', async () => {
const err = Object.assign(new Error('Service Unavailable'), { status: 503, body: '' });
const h = harness([() => Promise.reject(err)]);
const outcome = await h.probe.run('focus');
expect(outcome).toEqual({ kind: 'failed-open', detail: 'HTTP 503: Service Unavailable' });
expect(h.stale).toEqual([]);
});
test('an undecidable body does not trigger recovery', async () => {
const h = harness([{ upgradeNeeded: false }]);
expect((await h.probe.run('load')).kind).toBe('undecided');
expect(h.stale).toEqual([]);
});
test('a failed run still counts for the throttle — a failing pod is not hammered', async () => {
const h = harness([() => Promise.reject(new TypeError('Failed to fetch')), CURRENT]);
await h.probe.run('load');
await h.advance(1_000);
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: MIN_PROBE_INTERVAL_MS - 1_000 });
expect(h.calls).toHaveLength(1);
});
});
describe('createAppStatusProbe — stale readings', () => {
test('a revoked registration calls onStale once with not-installed', async () => {
const h = harness([REVOKED]);
await h.probe.run('load');
expect(h.stale).toEqual(['not-installed']);
});
test('an upgrade-needed registration calls onStale with upgrade-needed', async () => {
const h = harness([UPGRADE]);
await h.probe.run('visibility');
expect(h.stale).toEqual(['upgrade-needed']);
});
test('a current registration does not call onStale', async () => {
const h = harness([CURRENT]);
await h.probe.run('load');
expect(h.stale).toEqual([]);
});
test('a run waits for onStale, so no second probe starts during the recovery', async () => {
let finishRecovery: () => void = () => {};
let clock = 0;
const calls: number[] = [];
const probe = createAppStatusProbe({
hasSession: () => true,
getAppStatus: () => { calls.push(clock); return Promise.resolve(REVOKED); },
onStale: () => new Promise<void>((r) => { finishRecovery = r; }),
now: () => clock,
log: () => {},
});
const first = probe.run('load');
await Promise.resolve();
await Promise.resolve();
clock += MIN_PROBE_INTERVAL_MS * 2;
expect(await probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
finishRecovery();
await first;
expect(calls).toHaveLength(1);
});
});
describe('recoverRegistration', () => {
test('registerApp resolving undefined means the browser is leaving for consent', async () => {
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => undefined });
expect(outcome).toEqual({ kind: 'redirecting' });
});
test('registerApp resolving a URI means the registration is current after all', async () => {
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => 'http://pod/alice/reg/1' });
expect(outcome).toEqual({ kind: 'registered', registration: 'http://pod/alice/reg/1' });
});
test('registerApp throwing is a failed recovery, reported with its status', async () => {
const err = Object.assign(new Error('Forbidden'), { status: 403 });
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => { throw err; } });
expect(outcome).toEqual({ kind: 'failed', detail: 'HTTP 403: Forbidden' });
});
test('no session: registerApp is not called', async () => {
let called = false;
const outcome = await recoverRegistration({ getWebId: () => undefined, registerApp: async () => { called = true; return undefined; } });
expect(outcome).toEqual({ kind: 'no-session' });
expect(called).toBe(false);
});
test('registerApp receives the session webId', async () => {
let seen: string | undefined;
await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async (w) => { seen = w; return undefined; } });
expect(seen).toBe('http://pod/alice');
});
});
describe('resolveStaleRegistration', () => {
const failed = { kind: 'failed', detail: 'HTTP 502: Bad Gateway' } as const;
test('registered after all: the app stays connected, whatever was read', () => {
expect(resolveStaleRegistration('not-installed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
expect(resolveStaleRegistration('upgrade-needed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
});
test('consent redirect under way: the connected view is taken down', () => {
expect(resolveStaleRegistration('not-installed', { kind: 'redirecting' })).toBe('reauthorizing');
expect(resolveStaleRegistration('upgrade-needed', { kind: 'redirecting' })).toBe('reauthorizing');
});
test('session ended meanwhile: signed out, no registration-lost claim', () => {
expect(resolveStaleRegistration('not-installed', { kind: 'no-session' })).toBe('signed-out');
expect(resolveStaleRegistration('upgrade-needed', { kind: 'no-session' })).toBe('signed-out');
});
test('a revoked registration whose recovery failed is lost — never left connected', () => {
expect(resolveStaleRegistration('not-installed', failed)).toBe('registration-lost');
});
test('an upgrade whose recovery failed fails open — current grants still work', () => {
expect(resolveStaleRegistration('upgrade-needed', failed)).toBe('stay-connected');
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* App-status probe — re-checks, while a session exists, that the pod still
* holds a current registration for Festipod.
*
* WHY. Without it, a user who revokes the app on their pod (or whose grants go
* stale after an app upgrade) keeps seeing a "connected" Festipod whose reads
* come back empty and whose writes cannot land. Nothing re-checked the
* registration after the sign-in flow.
*
* THE MECHANISM IS THE PROVIDER'S (pinned contract, re-baseline 2026-10-12):
* `getAppStatus()` is a plain GET of the pod's `/.well-known/app-status` with
* the session token, and `registerApp(webId)` is the recovery — it resolves the
* AppRegistration URI when the registration is present and current, and
* otherwise sends the browser to the consent screen. The provider's own drive
* is disabled in Festipod (`appStatusCheckInterval: false`, usage declaration
* friction 1: it leaks a timer and a listener per construction), so this module
* is the app's own schedule for it.
*
* WHAT THE BODY SAYS (observed on the local pod v2.3.12, 2026-10-12, with the
* app's own session token — `scripts/e2e-revocation.ts` records it on each run):
* a current registration answers
* `{ onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [...] }`;
* after `POST {pod}/.auth-agent/app-registrations/remove` the same request
* answers `{ onlineBackend: true, installed: false, upgradeNeeded: false }`.
* A revocation therefore leaves `upgradeNeeded` FALSE — the provider's own
* check, which acts on `upgradeNeeded` only, would miss it. The probe treats
* `installed === false` as stale too.
*
* SCHEDULE. Event-driven, never a polling loop (the app has none, by
* doctrine): on app load once signed in, and when the window regains focus or
* becomes visible. Throttled — no two runs start less than a minute apart, and
* only one runs at a time. A trigger that lands inside the minute is NOT
* dropped: it is deferred to the end of the minute (one pending run at most,
* cancelled when the signed-in view goes away). Dropping it would miss exactly
* the case this exists for — a user who revokes in the pod's tab and comes
* straight back, then stays. A trigger that finds a run in flight is absorbed
* by that run, which is reading the status already; the one gap is a run that
* hangs past the minute (then fails open), after which the next focus or
* visibility probes again.
*
* FAILS OPEN. A probe that throws (network error, 5xx, anything) is logged and
* the app keeps going, mirroring the provider's own upgrade check: an
* unreachable status endpoint is not evidence of a revocation, and turning
* every pod hiccup into a sign-out would be worse than the hole it closes.
*/
/** Minimum time between two probe runs. Never below a minute. */
export const MIN_PROBE_INTERVAL_MS = 60_000;
/** What woke the probe up. */
export type ProbeTrigger = 'load' | 'focus' | 'visibility';
/** Why a registration is not usable as it stands. */
export type StaleReason = 'not-installed' | 'upgrade-needed';
/** The probe's reading of one status body. */
export type AppStatusVerdict =
| { kind: 'current' }
| { kind: 'stale'; reason: StaleReason }
/** The body did not say anything decidable — treated as current (fail open). */
| { kind: 'undecided'; detail: string };
/** The outcome of one `run()` call. */
export type ProbeOutcome =
/** `deferredMs`: when a throttled trigger will run (undefined: one is already pending). */
| { kind: 'skipped'; why: 'throttled'; deferredMs?: number }
| { kind: 'skipped'; why: 'in-flight' | 'no-session' }
| { kind: 'failed-open'; detail: string }
| AppStatusVerdict;
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/**
* Read a status body. `installed === false` (no registration for this app —
* what a revocation leaves) and `upgradeNeeded === true` (registered, grants
* out of date) are stale; a body that says installed and no upgrade is current.
* Anything else — a missing or non-boolean member, a non-object — is undecided,
* and undecided fails open like an error does.
*/
export function classifyAppStatus(body: unknown): AppStatusVerdict {
const status = asObject(body);
if (!status) return { kind: 'undecided', detail: `status body is not an object: ${JSON.stringify(body)}` };
if (status['installed'] === false) return { kind: 'stale', reason: 'not-installed' };
if (status['upgradeNeeded'] === true) return { kind: 'stale', reason: 'upgrade-needed' };
if (status['installed'] === true && status['upgradeNeeded'] === false) return { kind: 'current' };
return { kind: 'undecided', detail: `status body not decidable: ${JSON.stringify(status)}` };
}
/** Best-effort description of a thrown probe failure (the provider attaches `status`). */
function describeFailure(err: unknown): string {
const status = asObject(err)?.['status'];
const message = err instanceof Error ? err.message : String(err);
return typeof status === 'number' ? `HTTP ${status}: ${message}` : message;
}
export interface AppStatusProbeDeps {
/** Is there a session to probe for? No session → nothing to check. */
hasSession: () => boolean;
/** The provider's `getAppStatus()`. */
getAppStatus: () => Promise<unknown>;
/** Called once per stale reading; the probe waits for it before the next run can start. */
onStale: (reason: StaleReason) => Promise<void> | void;
/** Clock, injectable for tests. Defaults to `Date.now`. */
now?: () => number;
/** Logger, injectable for tests. Defaults to `console.info`. */
log?: (message: string) => void;
/** One-shot timer for a deferred trigger, injectable for tests. Defaults to `setTimeout`. */
setTimer?: (fn: () => void, ms: number) => unknown;
/** Cancels what `setTimer` returned. Defaults to `clearTimeout`. */
clearTimer?: (handle: unknown) => void;
}
export interface AppStatusProbe {
run(trigger: ProbeTrigger): Promise<ProbeOutcome>;
/** Drop a deferred trigger, if any — the signed-in view is going away. */
cancelPending(): void;
}
/**
* Build a throttled probe. Hold ONE per page session (the hook keeps it in a
* ref): the throttle lives in the instance, so a remount that rebuilt it would
* reset the minute.
*/
export function createAppStatusProbe(deps: AppStatusProbeDeps): AppStatusProbe {
const now = deps.now ?? Date.now;
const log = deps.log ?? ((message: string) => console.info(message));
const setTimer = deps.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
const clearTimer = deps.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
let lastStart: number | undefined;
let inFlight = false;
let pending: unknown;
const probe: AppStatusProbe = {
cancelPending() {
if (pending !== undefined) clearTimer(pending);
pending = undefined;
},
async run(trigger) {
if (inFlight) return { kind: 'skipped', why: 'in-flight' };
if (!deps.hasSession()) return { kind: 'skipped', why: 'no-session' };
const at = now();
if (lastStart !== undefined && at - lastStart < MIN_PROBE_INTERVAL_MS) {
if (pending !== undefined) return { kind: 'skipped', why: 'throttled' };
const deferredMs = lastStart + MIN_PROBE_INTERVAL_MS - at;
pending = setTimer(() => {
pending = undefined;
void probe.run(trigger);
}, deferredMs);
return { kind: 'skipped', why: 'throttled', deferredMs };
}
lastStart = at;
inFlight = true;
try {
let body: unknown;
try {
body = await deps.getAppStatus();
} catch (err) {
const detail = describeFailure(err);
log(`[AppStatus] probe (${trigger}) failed, failing open: ${detail}`);
return { kind: 'failed-open', detail };
}
const verdict = classifyAppStatus(body);
log(`[AppStatus] probe (${trigger}) → ${verdict.kind}${verdict.kind === 'stale' ? ` (${verdict.reason})` : ''} ${JSON.stringify(body)}`);
if (verdict.kind === 'stale') await deps.onStale(verdict.reason);
return verdict;
} finally {
inFlight = false;
}
},
};
return probe;
}
/** What re-running the registration flow led to. */
export type RecoveryOutcome =
/** `registerApp` resolved `undefined`: the browser is leaving for the consent screen. */
| { kind: 'redirecting' }
/** `registerApp` found a current registration after all — nothing to recover. */
| { kind: 'registered'; registration: string }
/** No session left to register for. */
| { kind: 'no-session' }
/** `registerApp` threw: no recovery from here. */
| { kind: 'failed'; detail: string };
export interface RecoveryDeps {
getWebId: () => string | undefined;
registerApp: (webId: string) => Promise<string | undefined>;
}
/**
* Re-run the registration flow for a stale reading — the provider's
* `registerApp`, whose consent redirect IS the recovery path. A resolved URI
* means the registration is present and current (the status endpoint and the
* registration lookup disagreed, e.g. a reading taken just before a consent
* settled): the provider's answer wins and the app stays connected.
*/
export async function recoverRegistration(deps: RecoveryDeps): Promise<RecoveryOutcome> {
const webId = deps.getWebId();
if (!webId) return { kind: 'no-session' };
try {
const registration = await deps.registerApp(webId);
return registration ? { kind: 'registered', registration } : { kind: 'redirecting' };
} catch (err) {
return { kind: 'failed', detail: describeFailure(err) };
}
}
/** What the app does with a stale reading once the recovery has run. */
export type StaleResolution =
/** Nothing to do: the registration is current after all, or a failed upgrade recovery fails open. */
| 'stay-connected'
/** The browser is leaving for the consent screen: take the connected view down meanwhile. */
| 'reauthorizing'
/** The session ended meanwhile: back to the picker, nothing to explain. */
| 'signed-out'
/** Registration gone and not re-runnable: the picker, with why, and no way back into the dead view. */
| 'registration-lost';
/**
* Decide from what was found (`reason`) and what the recovery led to. The split
* on a failed recovery is deliberate: a REVOKED registration (`not-installed`)
* can neither read nor write, so staying connected would be the silent breakage
* this exists to end; a registration that only needs an UPGRADE still works on
* its current grants, so a recovery that could not run fails open like a probe
* that could not read, and the next probe retries it.
*/
export function resolveStaleRegistration(reason: StaleReason, outcome: RecoveryOutcome): StaleResolution {
switch (outcome.kind) {
case 'registered': return 'stay-connected';
case 'redirecting': return 'reauthorizing';
case 'no-session': return 'signed-out';
case 'failed': return reason === 'upgrade-needed' ? 'stay-connected' : 'registration-lost';
}
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
import type { AuthProvider } from '@activitypods/refine-providers';
import { createAppStatusProbe, type AppStatusProbe, type StaleReason } from './appStatusProbe';
interface UseAppStatusProbeOptions {
/** The auth provider to probe through; `undefined` disables the probe. */
provider: AuthProvider | undefined;
/** Probe only while the app shows a signed-in view. */
active: boolean;
/** What to do on a stale reading (re-run the registration flow). */
onStale: (reason: StaleReason) => Promise<void> | void;
}
/**
* Drive the app-status probe while signed in: one run when the signed-in view
* mounts, then one per window `focus` / `visibilitychange` to visible, all
* subject to the probe's one-minute throttle (a trigger inside the minute is
* deferred to its end, not dropped).
*
* The listeners are attached in an effect and removed in its cleanup, which
* also cancels a deferred trigger: nothing probes once the signed-in view is
* gone, and a remount (StrictMode's double effect, a backend switch) never
* accumulates listeners or timers — the very leak that made the provider's
* built-in drive unusable here (usage declaration, friction 1). The probe
* instance — and with it the throttle — outlives those remounts in a ref, for
* the life of the page.
*/
export function useAppStatusProbe({ provider, active, onStale }: UseAppStatusProbeOptions): void {
const onStaleRef = useRef(onStale);
onStaleRef.current = onStale;
const probeRef = useRef<{ provider: AuthProvider; probe: AppStatusProbe } | undefined>(undefined);
useEffect(() => {
if (!active || !provider) return;
if (probeRef.current?.provider !== provider) {
probeRef.current = {
provider,
probe: createAppStatusProbe({
hasSession: () => provider.getSession() !== undefined,
getAppStatus: () => provider.getAppStatus(),
onStale: (reason) => onStaleRef.current(reason),
}),
};
}
const { probe } = probeRef.current;
void probe.run('load');
const onFocus = () => { void probe.run('focus'); };
const onVisibility = () => {
if (document.visibilityState === 'visible') void probe.run('visibility');
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
probe.cancelPending();
};
}, [active, provider]);
}
+3 -4
View File
@@ -67,10 +67,9 @@
* together, and the data/live providers take the auth provider as a parameter. Its
* `appStatusCheckInterval` is disabled deliberately: construction installs a
* `setInterval` and a `visibilitychange` listener with no teardown (usage
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
* the planned replacement, but it is NOT wired yet: the contract documents the
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
* declaration, friction 1). Festipod drives `getAppStatus()` on its own schedule
* instead — the app-status probe beside the auth flow (`src/app/auth/appStatusProbe.ts`),
* against the shape pinned in the contract's 2026-10-12 re-baseline. The `clientId` names
* the app; the app description document it points to
* is served by Festipod's own backend, and the values resolve from the runtime
* config — the SAME source the AuthGate flow uses, so both halves agree.