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:
@ -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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
92
frontend/src/adapters/http/webSession.test.ts
Normal file
92
frontend/src/adapters/http/webSession.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
175
frontend/src/adapters/http/webSession.ts
Normal file
175
frontend/src/adapters/http/webSession.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user