web: progress-portal serial-knowledge model — lookup client, serial proof on events/join, reconnect re-proof, visible join denials

- useProgressSearch calls the server-side lookup; the client-side filter
  over the tenant list is gone with the list itself.
- useProgressEvents passes the serial on every events read and refreshes
  the session summary via the lookup (guarded by sessionId so a newer
  re-enrollment of the same serial cannot swap the selected session).
- SignalRContext.joinGroup takes {serialNumber, onDenied}; the serial is
  remembered per group and re-presented on the automatic post-reconnect
  rejoin — without that, a roleless viewer's live updates would die on
  the first network blip. A refused join is now visible (console.warn +
  progress-page notification) instead of a silently frozen page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-15 09:42:36 +02:00
parent 789926fcad
commit f26721c0f7
6 changed files with 109 additions and 46 deletions
@@ -67,7 +67,7 @@ export function useProgressEvents({
const fetchEvents = async () => {
try {
const response = await authenticatedFetch(
api.progress.sessionEvents(session.sessionId, tenantId),
api.progress.sessionEvents(session.sessionId, tenantId, session.serialNumber),
getAccessToken,
);
if (response.ok) {
@@ -121,21 +121,24 @@ export function useProgressEvents({
const currentSession = sessionRef.current;
if (!currentSession) return;
try {
const sessionsResponse = await authenticatedFetch(
api.progress.sessions(tenantId),
// Refresh the session summary via the serial lookup (the canonical serial from the
// session row always matches exactly). Guard on sessionId: the lookup returns the
// NEWEST session for the serial, which after a re-enrollment is a different session —
// the page must keep showing the one the user selected.
const lookupResponse = await authenticatedFetch(
api.progress.lookup(tenantId, currentSession.serialNumber),
getAccessToken,
);
if (sessionsResponse.ok) {
const sessionsData = await sessionsResponse.json();
const sessions: Session[] = sessionsData.sessions || [];
const updated = sessions.find(
(s) => s.sessionId === currentSession.sessionId,
);
if (updated) setSession(updated);
if (lookupResponse.ok) {
const lookupData = await lookupResponse.json();
const updated: Session | null = lookupData.found ? lookupData.session : null;
if (updated && updated.sessionId === currentSession.sessionId) {
setSession(updated);
}
}
const eventsResponse = await authenticatedFetch(
api.progress.sessionEvents(currentSession.sessionId, tenantId),
api.progress.sessionEvents(currentSession.sessionId, tenantId, currentSession.serialNumber),
getAccessToken,
);
if (eventsResponse.ok) {
@@ -37,7 +37,9 @@ export interface UseProgressSearchReturn {
/**
* Owns the progress page's serial-number search lifecycle:
* - fetches tenant-wide sessions list, finds best match by serial / device name
* - resolves the session via the server-side lookup (exact serial/device-name match for
* roleless end users — the serial IS the authorization proof; members keep fuzzy search
* server-side; the tenant-wide list never reaches the browser)
* - exposes `setSession` so real-time refetch can replace the selected session
* - auto-collapses header on match, raises notFound on miss or error
*/
@@ -66,27 +68,13 @@ export function useProgressSearch({
try {
const response = await authenticatedFetch(
api.progress.sessions(tenantId),
api.progress.lookup(tenantId, serialInput.trim()),
getAccessToken,
);
if (response.ok) {
const data = await response.json();
const sessions: Session[] = data.sessions || [];
const query = serialInput.trim().toLowerCase();
const found = sessions
.filter(
(s) =>
s.serialNumber.toLowerCase() === query ||
s.serialNumber.toLowerCase().includes(query) ||
s.deviceName?.toLowerCase().includes(query),
)
.sort(
(a, b) =>
new Date(b.startedAt).getTime() -
new Date(a.startedAt).getTime(),
)[0];
const found: Session | null = data.found ? data.session : null;
if (found) {
setSession(found);
@@ -1,8 +1,10 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { Session } from "@/types";
import type { SignalRMessageName } from "@/lib/signalrMessages";
import type { JoinGroupOptions } from "@/contexts/SignalRContext";
import type { NotificationType } from "@/contexts/NotificationContext";
interface SignalRApi {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -10,24 +12,35 @@ interface SignalRApi {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
off: (event: SignalRMessageName, handler: (...args: any[]) => void) => void;
isConnected: boolean;
joinGroup: (group: string) => Promise<void>;
joinGroup: (group: string, options?: JoinGroupOptions) => Promise<void>;
leaveGroup: (group: string) => Promise<void>;
}
type AddNotification = (
type: NotificationType,
title: string,
message: string,
key?: string,
href?: string,
) => void;
interface UseProgressSignalRParams {
session: Session | null;
sessionRef: React.RefObject<Session | null>;
signalR: SignalRApi;
scheduleFetchEvents: (delayMs?: number) => void;
addNotification: AddNotification;
}
/**
* Owns the progress page's SignalR integration:
* - joins the session-specific group when a session is selected
* (the tenant-wide broadcast group is member-role gated server-side; roleless
* progress viewers would get a 403, and all signals for the selected session —
* eventStream on ingest, newevents on admin mark-succeeded/failed — arrive on
* the session group anyway)
* - joins the session-specific group when a session is selected, presenting the device's
* serial number as knowledge proof (roleless progress viewers are refused without it;
* the tenant-wide broadcast group stays member-role gated server-side, and all signals
* for the selected session — eventStream on ingest, newevents on admin
* mark-succeeded/failed — arrive on the session group anyway)
* - surfaces a refused join as a notification instead of a silently frozen page
* (a swallowed join 403 is exactly how the c4dabeee regression stayed invisible)
* - listens for newevents / newSession / eventStream → debounced refetch
* - cleans up groups + handlers on unmount / session change
*/
@@ -36,6 +49,7 @@ export function useProgressSignalR({
sessionRef,
signalR,
scheduleFetchEvents,
addNotification,
}: UseProgressSignalRParams): void {
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
@@ -43,19 +57,38 @@ export function useProgressSignalR({
// session, not object identity — a refetched session object must not rejoin.
const sessionId = session?.sessionId;
const sessionTenantId = session?.tenantId;
const sessionSerial = session?.serialNumber;
// Ref-stabilized so an unstable addNotification identity can never churn the join effect
// (which would leave + rejoin the group on every render).
const addNotificationRef = useRef(addNotification);
useEffect(() => {
addNotificationRef.current = addNotification;
}, [addNotification]);
useEffect(() => {
if (!isConnected || !sessionId || !sessionTenantId) return;
const sessionGroup = `session-${sessionTenantId}-${sessionId}`;
console.log("[Progress] Joining session group:", sessionGroup);
joinGroup(sessionGroup);
joinGroup(sessionGroup, {
serialNumber: sessionSerial,
onDenied: (status) => {
console.warn(`[Progress] Session group join denied (status ${status})`);
addNotificationRef.current(
"warning",
"Live Updates Unavailable",
"Real-time progress updates could not be enabled. The page will still refresh when you search again.",
"progress-join-denied",
);
},
});
return () => {
console.log("[Progress] Leaving session group:", sessionGroup);
leaveGroup(sessionGroup);
};
}, [isConnected, sessionId, sessionTenantId, joinGroup, leaveGroup]);
}, [isConnected, sessionId, sessionTenantId, sessionSerial, joinGroup, leaveGroup]);
useEffect(() => {
const scheduleRefetch = (source: string, sessionId: string) => {
@@ -68,6 +68,7 @@ export default function ProgressPortalPage() {
sessionRef,
signalR,
scheduleFetchEvents,
addNotification,
});
const { appSummary, currentDownload, currentInstall, installElapsedMs, overallProgress } =
@@ -12,6 +12,16 @@ import { useAuth } from './AuthContext';
// consumer handlers keep their narrower parameter types without laundering here.
type SignalRHandler = Parameters<signalR.HubConnection["on"]>[1];
// Session-group joins by roleless Progress-Portal users must present the device's serial number
// (server-side knowledge proof). The serial is remembered per group so the automatic re-join
// after a reconnect carries it too — without that, live updates would silently die on the first
// network blip. onDenied surfaces a join the server refused (403); the context otherwise only
// logs it, and a silent join failure historically looked like a frozen page.
export interface JoinGroupOptions {
serialNumber?: string;
onDenied?: (status: number) => void;
}
interface SignalRContextType {
connection: signalR.HubConnection | null;
connectionState: signalR.HubConnectionState;
@@ -21,7 +31,7 @@ interface SignalRContextType {
on: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
off: (eventName: SignalRMessageName, callback: SignalRHandler) => void;
invoke: (methodName: string, ...args: unknown[]) => Promise<unknown>;
joinGroup: (groupName: string) => Promise<void>;
joinGroup: (groupName: string, options?: JoinGroupOptions) => Promise<void>;
leaveGroup: (groupName: string) => Promise<void>;
isConnected: boolean;
joinedGroups: string[];
@@ -50,6 +60,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
const [connectionState, setConnectionState] = useState<signalR.HubConnectionState>(signalR.HubConnectionState.Disconnected);
const connectionRef = useRef<signalR.HubConnection | null>(null);
const joinedGroupsRef = useRef<Set<string>>(new Set());
// Serial-number proof per session group (see JoinGroupOptions). Kept across disconnects so the
// auto-rejoin after a reconnect can re-present it; entries are removed on a confirmed leave.
const joinSerialsRef = useRef<Map<string, string>>(new Map());
const [joinedGroups, setJoinedGroups] = useState<string[]>([]);
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -133,7 +146,13 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, groupName })
// Re-present the stored serial proof for session groups — a roleless
// Progress-Portal user's rejoin is refused without it.
body: JSON.stringify({
connectionId,
groupName,
serialNumber: joinSerialsRef.current.get(groupName),
})
}
);
@@ -246,7 +265,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
throw new Error('SignalR connection not established');
};
const joinGroup = useCallback(async (groupName: string) => {
const joinGroup = useCallback(async (groupName: string, options?: JoinGroupOptions) => {
if (!connection || connectionState !== signalR.HubConnectionState.Connected) {
return;
}
@@ -258,6 +277,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
// Add to Set immediately to prevent race conditions with multiple simultaneous calls
joinedGroupsRef.current.add(groupName);
if (options?.serialNumber) {
joinSerialsRef.current.set(groupName, options.serialNumber);
}
syncJoinedGroups();
try {
@@ -274,19 +296,29 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connectionId, groupName })
body: JSON.stringify({
connectionId,
groupName,
serialNumber: options?.serialNumber,
})
}
);
if (!response.ok) {
// Remove from Set if API call failed (so we can retry)
// Remove from Set if API call failed (so we can retry). A refused join must be
// VISIBLE: a silently swallowed 403 here historically left the page looking frozen
// (no live updates, no error) — see the c4dabeee regression.
console.warn(`[SignalR] Failed to join group ${groupName} (status ${response.status})`);
joinedGroupsRef.current.delete(groupName);
joinSerialsRef.current.delete(groupName);
syncJoinedGroups();
options?.onDenied?.(response.status);
}
} catch (error) {
console.error(`[SignalR] Error joining group ${groupName}:`, error);
// Remove from Set if API call failed (so we can retry)
joinedGroupsRef.current.delete(groupName);
joinSerialsRef.current.delete(groupName);
syncJoinedGroups();
}
}, [connection, connectionState, getAccessToken, syncJoinedGroups]);
@@ -325,6 +357,9 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) {
// Re-add to Set if API call failed (so we can retry)
joinedGroupsRef.current.add(groupName);
syncJoinedGroups();
} else {
// Confirmed leave — drop the stored serial proof for this group.
joinSerialsRef.current.delete(groupName);
}
} catch (error) {
console.error(`[SignalR] Error leaving group ${groupName}:`, error);
+7 -4
View File
@@ -389,11 +389,14 @@ export const api = {
},
// ── Progress ──────────────────────────────────────────────────────────────
// Serial-knowledge model: there is NO tenant-wide session list at this tier. The lookup
// resolves at most one session server-side (roleless callers need the exact serial/device
// name), and the events route re-presents the serial as proof on every read.
progress: {
sessions: (tenantId: string) =>
`${API_BASE_URL}/api/progress/sessions${qs({ tenantId })}`,
sessionEvents: (sessionId: string, tenantId: string) =>
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId })}`,
lookup: (tenantId: string, search: string) =>
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`,
},
// ── Bootstrap ─────────────────────────────────────────────────────────────