Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -399,11 +399,11 @@ test("a stale source error cannot clear or mutate the next user's live session",
|
||||
expect(useSessionStore.getState().transcript).toEqual([{ role: "assistant", text: "B-data" }]);
|
||||
});
|
||||
|
||||
test("a native current-source error probes /me once and clears auth on a guarded 401", async () => {
|
||||
test.each([401, 403])("a native current-source error coalesces access probes and clears auth on a guarded %s", async (status) => {
|
||||
let probes = 0;
|
||||
server.use(http.get("/api/me", () => {
|
||||
probes += 1;
|
||||
return new HttpResponse(null, { status: 401 });
|
||||
return new HttpResponse(null, { status });
|
||||
}));
|
||||
setAuthState({
|
||||
issuer: "local", subject: "user-a", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
@@ -422,7 +422,7 @@ test("a native current-source error probes /me once and clears auth on a guarded
|
||||
expect((await import("../auth/authState")).getAuthState()).toBeNull();
|
||||
});
|
||||
|
||||
test("a native error keeps current auth connected after a 200 /me probe and does not storm", async () => {
|
||||
test("a successful access probe does not claim the stream is connected and a later error rechecks access", async () => {
|
||||
let probes = 0;
|
||||
const user = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
@@ -430,18 +430,26 @@ test("a native error keeps current auth connected after a 200 /me probe and does
|
||||
};
|
||||
server.use(http.get("/api/me", () => {
|
||||
probes += 1;
|
||||
return HttpResponse.json(user);
|
||||
return HttpResponse.json({ ...user, csrfToken: "b".repeat(43) });
|
||||
}));
|
||||
setAuthState(user);
|
||||
const result = renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
|
||||
const source = FakeEventSource.instances[0];
|
||||
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
await waitFor(() => expect(result.result.current.connected).toBe(true));
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
act(() => {
|
||||
source.onerror?.(new Event("error"));
|
||||
source.onerror?.(new Event("error"));
|
||||
});
|
||||
await waitFor(() => expect(getAuthState()?.csrfToken).toBe("b".repeat(43)));
|
||||
|
||||
expect(probes).toBe(1);
|
||||
expect(result.result.current.connected).toBe(false);
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-a" });
|
||||
act(() => source.onopen?.());
|
||||
expect(result.result.current.connected).toBe(true);
|
||||
server.use(http.get("/api/me", () => new HttpResponse(null, { status: 403 })));
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
await waitFor(() => expect(getAuthState()).toBeNull());
|
||||
});
|
||||
|
||||
test("a stale source does not probe and a user-B login during an A probe cannot mutate B", async () => {
|
||||
@@ -478,3 +486,23 @@ test("a stale source does not probe and a user-B login during an A probe cannot
|
||||
expect(getAuthState()).toMatchObject({ subject: "user-b" });
|
||||
expect(sourceB.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("opening after an outage revalidates access even when the outage probe could not reach the server", async () => {
|
||||
let probes = 0;
|
||||
const user = {
|
||||
issuer: "local", subject: "user-a", roles: ["user"] as const, permissions: ["session.use"], isAdmin: false,
|
||||
csrfToken: "a".repeat(43), session: null,
|
||||
};
|
||||
server.use(http.get("/api/me", () => {
|
||||
probes += 1;
|
||||
return probes === 1 ? HttpResponse.error() : new HttpResponse(null, { status: 403 });
|
||||
}));
|
||||
setAuthState(user);
|
||||
renderHook(() => useSessionStream("s1", 0, 0, true, getAuthGeneration()));
|
||||
const source = FakeEventSource.instances[0];
|
||||
act(() => source.onerror?.(new Event("error")));
|
||||
await waitFor(() => expect(probes).toBe(1));
|
||||
act(() => source.onopen?.());
|
||||
await waitFor(() => expect(getAuthState()).toBeNull());
|
||||
expect(probes).toBe(2);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getAuthGeneration,
|
||||
isAuthGenerationCurrent,
|
||||
} from "../auth/authState";
|
||||
import { getMe } from "../api/auth";
|
||||
import { checkAccess } from "../auth/checkAccess";
|
||||
import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation";
|
||||
|
||||
const STREAM_UPDATE_INTERVAL_MS = 100;
|
||||
@@ -120,28 +120,30 @@ export function useSessionStream(
|
||||
const sourceAuthGeneration = effectiveAuthGeneration;
|
||||
const es = new EventSource(joinBackendPath(BASE, `/sessions/${sessionId}/events${query}`));
|
||||
const identity = { source: es, sessionId, cursorResetEpoch };
|
||||
let authProbeStarted = false;
|
||||
let authProbe: Promise<void> | null = null;
|
||||
const isCurrentSource = () => activeSource.current === identity
|
||||
&& isAuthGenerationCurrent(sourceAuthGeneration);
|
||||
const probeAuth = async () => {
|
||||
if (authProbeStarted || !isCurrentSource()) return;
|
||||
authProbeStarted = true;
|
||||
const probeAuth = (): Promise<void> => {
|
||||
if (authProbe) return authProbe;
|
||||
if (!isCurrentSource()) return Promise.resolve();
|
||||
const operation = captureAuthOperation({ sessionId, disposalEpoch: 0 });
|
||||
if (!operation) return;
|
||||
try {
|
||||
await getMe();
|
||||
if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return;
|
||||
setConnected(true);
|
||||
} catch {
|
||||
if (!isCurrentSource() || !isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) return;
|
||||
setConnected(false);
|
||||
}
|
||||
if (!operation) return Promise.resolve();
|
||||
authProbe = checkAccess().then(() => {}, () => {
|
||||
if (isCurrentSource() && isAuthOperationCurrent(operation, { sessionId, disposalEpoch: 0 })) {
|
||||
setConnected(false);
|
||||
}
|
||||
}).finally(() => { authProbe = null; });
|
||||
return authProbe;
|
||||
};
|
||||
const coalescer = createStreamEventCoalescer(applyEvent, STREAM_UPDATE_INTERVAL_MS, isCurrentSource);
|
||||
activeSource.current = identity;
|
||||
es.onopen = () => {
|
||||
if (!isCurrentSource()) { es.close(); return; }
|
||||
setConnected(true);
|
||||
// An outage probe may still be failing as the stream reopens. Read /me
|
||||
// after it settles so a successful reconnect always refreshes identity.
|
||||
const pending = authProbe ?? Promise.resolve();
|
||||
void pending.then(() => probeAuth());
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (!isCurrentSource()) { es.close(); return; }
|
||||
|
||||
Reference in New Issue
Block a user