feat(frontend): client web read-only pairing + snapshot état (#13)

Lot F2 du chantier server/client mode : client web read-only complétant le
premier incrément livrable — pairing, liste des projets, ouverture et
snapshot de l'état, sans PTY.

- frontend/src/adapters/http/webSession.ts : session web (pairing/cookie).
- frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index.
- Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401).
- Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans
  httpInvoker.test.ts.

Validé : frontend 736 tests verts, build vert, garde no-direct-invoke
verte, contrat B4↔F2 aligné, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:21:35 +02:00
parent fa353f6c0b
commit e500e31663
11 changed files with 768 additions and 3 deletions

View File

@ -77,6 +77,29 @@ describe("HttpInvoker", () => {
});
});
it("fires onUnauthorized on a 401 (and still throws)", async () => {
const { fetchImpl } = fakeFetch(
{ code: "UNAUTHENTICATED", message: "no session" },
{ ok: false, status: 401 },
);
const onUnauthorized = vi.fn();
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
await expect(http.invoke("list_projects")).rejects.toMatchObject({
code: "UNAUTHENTICATED",
});
expect(onUnauthorized).toHaveBeenCalledOnce();
});
it("does not fire onUnauthorized on a non-401 error", async () => {
const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 });
const onUnauthorized = vi.fn();
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy();
expect(onUnauthorized).not.toHaveBeenCalled();
});
it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => {
const fetchImpl: FetchLike = async () => {
throw new Error("boom");

View File

@ -30,6 +30,8 @@ export type FetchLike = (
headers?: Record<string, string>;
body?: string;
signal?: AbortSignal;
/** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */
credentials?: "same-origin" | "include" | "omit";
},
) => Promise<{
ok: boolean;
@ -47,6 +49,12 @@ export interface HttpInvokerConfig {
token?: string;
/** Injected fetch (defaults to global `fetch`). */
fetchImpl?: FetchLike;
/**
* Called when the backend answers `401` (the session cookie is missing or
* expired). F2 wires this to the web session so the app routes back to the
* pairing screen. The `GatewayError` is still thrown to the caller.
*/
onUnauthorized?: () => void;
}
/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
@ -71,10 +79,12 @@ export class HttpInvoker {
private readonly baseUrl: string;
private readonly token?: string;
private readonly fetchImpl: FetchLike;
private readonly onUnauthorized?: () => void;
constructor(config: HttpInvokerConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.token = config.token;
this.onUnauthorized = config.onUnauthorized;
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
// to the minimal shape used here.
this.fetchImpl =
@ -98,6 +108,9 @@ export class HttpInvoker {
method: "POST",
headers,
body: JSON.stringify({ command, args }),
// Same-origin so the HttpOnly session cookie is sent automatically
// (ticket #13: no secret in the URL/headers from JS).
credentials: "same-origin",
});
} catch (networkError) {
const err: GatewayError = {
@ -108,6 +121,9 @@ export class HttpInvoker {
}
if (!res.ok) {
// A 401 means the session cookie is missing/expired: signal the app to
// route back to pairing (the error is still thrown to the caller).
if (res.status === 401) this.onUnauthorized?.();
// Preserve the backend `ErrorDto` when present; fall back to the status.
let parsed: unknown;
try {

View File

@ -18,6 +18,7 @@ import type { Gateways } from "@/ports";
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
import { HttpInvoker } from "./httpInvoker";
import { WsLiveClient } from "./wsLiveClient";
import { getWebSession } from "./webSession";
import {
HttpConversationGateway,
HttpEmbedderGateway,
@ -77,7 +78,13 @@ function resolveEndpoints(config: HttpWsGatewaysConfig): {
*/
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
const { baseUrl, wsUrl } = resolveEndpoints(config);
const http = new HttpInvoker({ baseUrl, token: config.token });
// Route 401s back to the pairing screen via the shared web session (F2).
const session = getWebSession();
const http = new HttpInvoker({
baseUrl,
token: config.token,
onUnauthorized: () => session.notifyUnauthorized(),
});
const ws = new WsLiveClient({ wsUrl, token: config.token });
return {
@ -108,3 +115,4 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
export { HttpInvoker } from "./httpInvoker";
export { WsLiveClient } from "./wsLiveClient";
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";

View File

@ -0,0 +1,92 @@
/**
* F2 — the web session: pairing handshake (success marks the flag; wrong code
* rejects with a clear message), and the 401 → unauthorized transition that
* clears the flag and notifies subscribers.
*/
import { describe, it, expect, vi } from "vitest";
import type { FetchLike } from "./httpInvoker";
import { WebSession, type FlagStore } from "./webSession";
function memStore(): FlagStore {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function fetchReturning(status: number, body: unknown): {
fetchImpl: FetchLike;
calls: { url: string; init: unknown }[];
} {
const calls: { url: string; init: unknown }[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
};
};
return { fetchImpl, calls };
}
describe("WebSession pairing", () => {
it("POSTs the code to /api/pair and marks the flag on success", async () => {
const store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { ok: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
expect(session.isPaired()).toBe(false);
await session.pair("4821-93");
expect(session.isPaired()).toBe(true);
expect(calls[0].url).toBe("https://host/api/pair");
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" });
});
it("rejects a wrong code with a clear message and stays unpaired", async () => {
const store = memStore();
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
expect(session.isPaired()).toBe(false);
});
it("falls back to a default message when the server sends no body", async () => {
const { fetchImpl } = fetchReturning(403, undefined);
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("x")).rejects.toMatchObject({
code: "INVALID_PAIRING_CODE",
message: "Code d'appairage invalide.",
});
});
});
describe("WebSession unauthorized handling", () => {
it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
const store = memStore();
const session = new WebSession({ baseUrl: "https://host", store });
session.markPaired();
const handler = vi.fn();
session.onUnauthorized(handler);
session.notifyUnauthorized();
expect(session.isPaired()).toBe(false);
expect(handler).toHaveBeenCalledOnce();
});
it("forget() clears the flag (best-effort local sign-out)", () => {
const store = memStore();
const session = new WebSession({ baseUrl: "https://host", store });
session.markPaired();
session.forget();
expect(session.isPaired()).toBe(false);
});
});

View File

@ -0,0 +1,175 @@
/**
* Web client session (pairing + auth routing) — ticket #13, lot F2.
*
* The real authentication material is a session cookie set by the server at
* pairing (`POST /api/pair {code}` → `Set-Cookie: … HttpOnly; Secure;
* SameSite=Strict`). Because it is **HttpOnly** the browser sends it
* automatically on same-origin requests but JS can never read it. So this module
* keeps only a lightweight, JS-visible **"paired" flag** (in `localStorage`) to
* decide which screen to render — never the secret itself.
*
* On a `401` from any `/api/invoke`, the {@link HttpInvoker} calls
* {@link WebSession.notifyUnauthorized}, which clears the flag and notifies
* subscribers so the app routes back to the pairing screen. "Forget" is a
* best-effort local clear (server-side cookie revocation is B8).
*
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
*/
import type { GatewayError, Unsubscribe } from "@/domain";
import type { FetchLike } from "./httpInvoker";
const PAIRED_FLAG_KEY = "idea.web.paired";
/** Minimal `localStorage`-like surface, injectable for tests. */
export interface FlagStore {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Configuration for a {@link WebSession}. */
export interface WebSessionConfig {
/** Backend base URL (no trailing slash). Defaults to the page origin. */
baseUrl?: string;
/** Injected fetch (defaults to global `fetch`, with same-origin credentials). */
fetchImpl?: FetchLike;
/** Injected flag store (defaults to `window.localStorage`, else in-memory). */
store?: FlagStore;
}
/** In-memory fallback when `localStorage` is unavailable (SSR/tests). */
function memoryStore(): FlagStore {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function defaultStore(): FlagStore {
try {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage;
}
} catch {
/* access can throw in a sandboxed iframe; fall through */
}
return memoryStore();
}
function defaultBaseUrl(): string {
return typeof window !== "undefined" && window.location
? window.location.origin
: "http://localhost";
}
/**
* The web session state machine. One instance is shared between the pairing UI
* and the HTTP invoker (via the module singleton below).
*/
export class WebSession {
private readonly baseUrl: string;
private readonly fetchImpl: FetchLike;
private readonly store: FlagStore;
private readonly unauthorizedHandlers = new Set<() => void>();
constructor(config: WebSessionConfig = {}) {
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
this.store = config.store ?? defaultStore();
}
/** Whether the client believes it is paired (a session cookie should exist). */
isPaired(): boolean {
return this.store.getItem(PAIRED_FLAG_KEY) === "1";
}
/** Records the paired flag (called after a successful pair). */
markPaired(): void {
this.store.setItem(PAIRED_FLAG_KEY, "1");
}
/** Best-effort local sign-out: clears the flag (server revocation is B8). */
forget(): void {
this.store.removeItem(PAIRED_FLAG_KEY);
}
/**
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
* server sets the session cookie and this records the paired flag. A wrong code
* rejects with a {@link GatewayError} carrying a clear message.
*/
async pair(code: string): Promise<void> {
let res: Awaited<ReturnType<FetchLike>>;
try {
res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
} catch (networkError) {
const err: GatewayError = {
code: "TRANSPORT_ERROR",
message: `Impossible de joindre le serveur : ${String(networkError)}`,
};
throw err;
}
if (!res.ok) {
let parsed: unknown;
try {
parsed = await res.json();
} catch {
parsed = undefined;
}
const message =
parsed && typeof parsed === "object" && "message" in parsed
? String((parsed as GatewayError).message)
: res.status === 401 || res.status === 403
? "Code d'appairage invalide."
: `Échec de l'appairage (HTTP ${res.status}).`;
const err: GatewayError = {
code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED",
message,
};
throw err;
}
// Cookie is now set by the server (HttpOnly — not read here); record the flag.
this.markPaired();
}
/**
* Called by the HTTP invoker on a `401`: the session cookie is missing/expired.
* Clears the flag and notifies subscribers so the app returns to pairing.
*/
notifyUnauthorized(): void {
this.forget();
for (const handler of this.unauthorizedHandlers) handler();
}
/** Subscribes to unauthorized transitions (returns an unsubscribe). */
onUnauthorized(handler: () => void): Unsubscribe {
this.unauthorizedHandlers.add(handler);
return () => this.unauthorizedHandlers.delete(handler);
}
}
// ---------------------------------------------------------------------------
// Module singleton (shared between the invoker and the pairing UI)
// ---------------------------------------------------------------------------
let singleton: WebSession | null = null;
/** Returns the shared web session, creating it on first use. */
export function getWebSession(): WebSession {
if (!singleton) singleton = new WebSession();
return singleton;
}
/** Overrides the singleton (tests). Pass `null` to reset to a fresh default. */
export function setWebSessionForTests(session: WebSession | null): void {
singleton = session;
}