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
+1
View File
@@ -11,6 +11,7 @@
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
"test:auth-setup": "bun scripts/setup-test-auth.ts",
"test:e2e-flow": "bun scripts/e2e-login-flow.ts",
"test:e2e-revocation": "bun scripts/e2e-revocation.ts",
"cucumber:report": "bun scripts/parse-test-results.ts",
"features:parse": "bun scripts/parse-features.ts",
"steps:extract": "bun scripts/extract-step-definitions.ts",
+405
View File
@@ -0,0 +1,405 @@
/**
* Revocation probe: drives the REAL local ActivityPods stack through the browser
* and asserts that Festipod notices when its registration on the user's pod is
* revoked, instead of staying in a "connected" view whose writes cannot land.
*
* THREE PHASES, one fresh user:
*
* 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on
* load (the GET of the pod's `/.well-known/app-status` is observed on the
* wire, with its body, and the app logs `[AppStatus] probe (load) → current`);
* a focus + visibilitychange right after does NOT probe at once (one-minute
* throttle); an event is created and read back from the pod; a
* visibilitychange inside the minute is deferred, not dropped — its probe
* lands at the end of the minute and reads current.
* 2. REVOKED — the registration is removed on the pod
* (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token).
* (a) Without any reload, a window focus inside the minute — the user coming
* back from the pod's tab — must, by the end of that minute, read the
* revocation and take the app off the connected view: the consent screen
* (the provider's `registerApp` redirect) or the picker. (b) Reloading the
* app on the event form must do the same through the load probe, and the
* event-creation drive that worked in phase 1 must not reach success: no
* "Événement relayé", no event page, and nothing with that title on the pod.
* 3. RECOVERED — accepting the consent screen brings the user back to a
* connected app whose probe reads current again, and a write lands.
*
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
* is triggered only through the app's real paths (page load, window focus,
* visibilitychange); its one-minute throttle is untouched.
*
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
* `http://localhost:4000/login` only, so the app MUST be served at APP_URL.
*
* Exit code 0 only when every phase holds; the JSON report says which did not.
*/
import { chromium, type Page } from 'playwright';
import {
linkId,
podLogin,
readResource,
recordUri,
type ApodsSession,
type ApodsStackConfig,
} from '../src/shared/support/activitypodsStack';
const strip = (u: string): string => u.replace(/\/+$/, '');
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
const config: ApodsStackConfig = {
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
backendUrl: BACKEND,
};
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
const USER = `revoke${Date.now()}`;
const PASS = 'Test1test';
const DEADLINE = Date.now() + 10 * 60 * 1000;
type Phase = 'current' | 'revoked' | 'recovered';
let phase: Phase = 'current';
interface StatusSeen { phase: Phase; status: number; body: unknown }
interface HttpError { phase: Phase; url: string; status: number; method: string; body: string }
const statusSeen: StatusSeen[] = [];
const probeLogs: Array<{ phase: Phase; at: number; text: string }> = [];
const errors4xx: HttpError[] = [];
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
const consoleErrors: string[] = [];
const redirects: string[] = [];
const hops: string[] = [];
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
const bodyText = (page: Page): Promise<string> =>
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
const snap = (page: Page, name: string): Promise<void> =>
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
function wire(page: Page): void {
let last = '';
page.on('framenavigated', f => {
if (f === page.mainFrame() && f.url() !== last) {
redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`);
last = f.url();
}
});
page.on('response', r => {
const method = r.request()?.method?.() ?? '?';
const url = r.url();
if (method === 'GET' && url.endsWith('/.well-known/app-status')) {
const at = phase;
r.json()
.then(body => statusSeen.push({ phase: at, status: r.status(), body }))
.catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' }));
}
if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) {
writes.push({ phase, method, url: clean(url), status: r.status() });
}
if (r.status() >= 400) {
const at = phase;
r.text()
.then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) }))
.catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' }));
}
});
page.on('console', m => {
const text = m.text();
if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) });
if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`);
});
page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`));
}
const onApp = (page: Page): boolean => page.url().startsWith(APP);
const pickerShown = async (page: Page): Promise<boolean> =>
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
const whereIs = async (page: Page): Promise<string> =>
!onApp(page)
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
: (await pickerShown(page)) ? 'picker' : 'still-connected';
/** Drive pod login → consent until the connected app shell is reached. */
async function driveToApp(page: Page): Promise<boolean> {
let authorizeSeen = 0;
for (let i = 0; i < 25 && Date.now() < DEADLINE; i++) {
const url = page.url();
if (hops[hops.length - 1] !== `[${phase}] ${clean(url)}`) hops.push(`[${phase}] ${clean(url)}`);
if (url.startsWith(POD_FRONT) && (await page.locator('input[name="username"]').count().catch(() => 0))) {
hops.push(` [${phase}] [login] submitting credentials`);
await page.locator('input[name="username"]').fill(USER).catch(() => {});
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
if (await btn.count()) await btn.first().click().catch(() => {});
else await page.locator('button[type=submit]').first().click().catch(() => {});
await page.waitForTimeout(3500);
continue;
}
if (url.startsWith(`${POD_FRONT}/authorize`)) {
authorizeSeen++;
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
const visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
hops.push(` [${phase}] [authorize #${authorizeSeen}] accept visible=${visible}`);
await snap(page, `revocation-${phase}-consent`);
if (visible) await acc.first().click().catch(() => {});
if (authorizeSeen >= 3) { hops.push(` [${phase}] [LOOP] authorize revisited 3x`); return false; }
await page.waitForTimeout(5000);
continue;
}
if (onApp(page)) {
if (url.includes('/login')) { await page.waitForTimeout(3000); continue; }
if (!(await pickerShown(page)) && !(await page.locator('#auth-reauthorizing').count().catch(() => 0))) {
hops.push(` [${phase}] [app] shell reached`);
return true;
}
}
await page.waitForTimeout(1500);
}
return false;
}
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
let successToast = false;
const toastWatch = setInterval(() => {
void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {});
}, 250);
try {
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {});
const dates = page.locator('input[type="date"]');
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {});
await page.waitForTimeout(1200);
const next = page.getByRole('button', { name: /Suivant/ });
if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {});
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {});
await page.waitForTimeout(5000);
} finally {
clearInterval(toastWatch);
}
const finalUrl = page.url();
const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null;
const segment = m?.[1];
const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined;
return { eventUri, successToast, finalUrl };
}
/** Does the user's pod hold an event with this title? Read with the user's own token. */
async function podHoldsEventTitled(session: ApodsSession, title: string): Promise<boolean> {
const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined);
if (!container) return false;
if (JSON.stringify(container).includes(title)) return true;
const raw = container['ldp:contains'] ?? container['contains'];
const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u);
for (const uri of members) {
const rec = await readResource(session, uri).catch(() => undefined);
if (rec && JSON.stringify(rec).includes(title)) return true;
}
return false;
}
/** Wait until a predicate holds or the bound elapses. */
async function waitFor(pred: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
const end = Date.now() + ms;
while (Date.now() < end) {
if (await pred()) return true;
await sleep(250);
}
return pred();
}
/** Fire the real DOM event a probe listener is attached to. */
const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise<void> =>
page.evaluate((which) => {
if (which === 'focus') window.dispatchEvent(new Event('focus'));
else document.dispatchEvent(new Event('visibilitychange'));
}, trigger).catch(() => {});
const lastProbeAt = (): number =>
Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at));
/**
* The probe's own schedule, exercised live and never shortened: fire `trigger`
* less than a minute after the app's last probe, check that NO probe starts at
* once (throttle), then wait for the deferred probe of that trigger to land
* with the expected verdict at the end of the minute (not dropped).
*/
async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{
firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number;
}> {
const last = lastProbeAt();
const firedAfterLastProbeMs = Date.now() - last;
const before = probeLogs.length;
await fire(page, trigger);
await page.waitForTimeout(3000);
const immediate = probeLogs.slice(before).some(l => l.text.includes('probe ('));
const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`);
const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000);
const hit = probeLogs.slice(before).find(l => pattern.test(l.text));
return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined };
}
const isCurrent = (b: unknown): boolean =>
typeof b === 'object' && b !== null && (b as Record<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['upgradeNeeded'] === false;
/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */
function benign(e: HttpError): boolean {
if (e.url.includes('/.notifications/WebSocketChannel2023')) return true;
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true;
return false;
}
(async () => {
const user = await podLogin(config, USER, PASS);
const appDescription = await readResource(undefined, `${BACKEND}/app`);
const appUri = recordUri(appDescription);
if (!appUri) throw new Error('GET /app carries no id (the app actor URI)');
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
const ctx = await browser.newContext();
const page = await ctx.newPage();
wire(page);
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
const connected = await driveToApp(page);
const probeRanCurrent = connected && await waitFor(
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
15000,
);
// Throttle, observed through the real listeners: focus + visibilitychange
// seconds after the load probe must not issue another status request.
const beforeNudge = statusSeen.filter(s => s.phase === 'current').length;
await page.evaluate(() => {
window.dispatchEvent(new Event('focus'));
document.dispatchEvent(new Event('visibilitychange'));
}).catch(() => {});
await page.waitForTimeout(3000);
const afterNudge = statusSeen.filter(s => s.phase === 'current').length;
const throttled = beforeNudge >= 1 && afterNudge === beforeNudge;
const titleA = `Concert courant ${USER}`;
const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false;
await snap(page, 'revocation-current-after-write');
// A visibilitychange right after the write's reload: deferred, then current.
const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined;
// ── Phase 2: REVOKED ────────────────────────────────────────────────────
phase = 'revoked';
const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` },
body: JSON.stringify({ appUri }),
});
const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) };
// Removal is processed by the pod and the app backend; give it a moment to settle.
await sleep(3000);
// (a) FOCUS, no reload — the user revokes in the pod's tab and comes back
// within the minute of the last probe: the deferred focus probe must read the
// revocation and take the app off the connected view.
const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/);
const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
const leftByFocusTo = await whereIs(page);
await snap(page, 'revocation-revoked-left-by-focus');
// (b) RELOAD on the write path — the same event-creation drive that worked in
// phase 1: the load probe must take the app off again, and nothing may
// pretend the write succeeded.
const titleB = `Concert revoque ${USER}`;
const loadLogsBefore = probeLogs.length;
const writeB = await createEvent(page, titleB);
await snap(page, 'revocation-revoked-after-write-attempt');
const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
const leftAfterReloadTo = await whereIs(page);
const loadProbeSawRevoked = probeLogs.slice(loadLogsBefore).some(l => /probe \(load\) → stale \(not-installed\)/.test(l.text));
const writeBOnPod = await podHoldsEventTitled(user, titleB);
await snap(page, 'revocation-revoked-left');
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
phase = 'recovered';
const reconnected = leftAfterReloadTo === 'consent' ? await driveToApp(page) : false;
const probeReadsCurrentAgain = reconnected && await waitFor(
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
15000,
);
const titleC = `Concert retabli ${USER}`;
const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false;
await snap(page, 'revocation-recovered-after-write');
await ctx.close();
await browser.close();
await sleep(1000);
// The deferred probe lands at the end of the minute: never before (throttle),
// and not much after (it was deferred, not dropped). Measured between the
// app's own log lines, so a few hundred ms of request latency either way.
const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean =>
!!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined
&& p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000;
const positive = {
connected,
probeRanCurrent,
throttled,
statusRequestsAfterNudge: afterNudge,
write: { ...writeA, onPod: writeAOnPod },
visibilityProbe,
ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod
&& deferredOnTime(visibilityProbe),
};
const negative = {
revoke: revokeResult,
focusProbe,
leftByFocus,
leftByFocusTo,
loadProbeSawRevoked,
leftAfterReload,
leftAfterReloadTo,
writeAttempt: { ...writeB, onPod: writeBOnPod },
ok: (revoke.status === 200 || revoke.status === 204)
&& deferredOnTime(focusProbe) && leftByFocus && (leftByFocusTo === 'consent' || leftByFocusTo === 'picker')
&& loadProbeSawRevoked && leftAfterReload && (leftAfterReloadTo === 'consent' || leftAfterReloadTo === 'picker')
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
};
const recovered = {
reconnected,
probeReadsCurrentAgain,
write: { ...writeC, onPod: writeCOnPod },
ok: reconnected && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
};
// Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean.
const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e));
const report = {
user: USER,
appUri,
positive,
negative,
recovered,
statusBodies: statusSeen,
probeLogs,
writes,
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
fatal4xx: fatal,
redirects,
hops,
consoleErrors,
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
};
console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);
})().catch(err => {
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2));
process.exit(1);
});
+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.