feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)
Expose la gestion des appareils appairés introduite en B1-B4, sur une surface unique partagée par le web et le desktop. - Écran Appareils : liste, renommage, révocation unitaire ou globale, activité formatée, panneau de code éphémère. - Le parcours d'appairage demande un nom d'appareil, pour qu'une révocation porte sur quelque chose d'identifiable par l'utilisateur. - Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port restant le seul contrat connu de la feature. Les erreurs sont mappées localement et le message du serveur n'est jamais affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour qui teste des codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
51
frontend/src/adapters/http/deviceGateway.ts
Normal file
51
frontend/src/adapters/http/deviceGateway.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* HTTP device gateway (ticket #77, lot F2) — the web sibling of
|
||||
* `TauriDeviceGateway`, behind the unchanged {@link DeviceGateway} port.
|
||||
*
|
||||
* Routes live outside the generic `/api/invoke` RPC, next to the pre-existing
|
||||
* `/api/pair` and `/api/logout`: the RPC endpoint is auth-gated, so the surface
|
||||
* that manages auth cannot ride on it (carnet #77).
|
||||
*
|
||||
* Paths are those B3 landed (`web-server/src/lib.rs`).
|
||||
*/
|
||||
|
||||
import type { PairedDevice, PairingCode } from "@/domain";
|
||||
import type { DeviceGateway } from "@/ports";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
|
||||
/** Backend shape of `GET /api/devices`: the list is wrapped, never bare. */
|
||||
interface DeviceListResponse {
|
||||
devices: PairedDevice[];
|
||||
}
|
||||
|
||||
export class HttpDeviceGateway implements DeviceGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
|
||||
async listDevices(): Promise<PairedDevice[]> {
|
||||
const body = await this.http.request<DeviceListResponse>("GET", "/api/devices");
|
||||
return body.devices;
|
||||
}
|
||||
|
||||
createPairingCode(): Promise<PairingCode> {
|
||||
return this.http.request<PairingCode>("POST", "/api/pairing-code");
|
||||
}
|
||||
|
||||
renameDevice(deviceId: string, name: string): Promise<void> {
|
||||
return this.http.request<void>(
|
||||
"POST",
|
||||
`/api/devices/${encodeURIComponent(deviceId)}/rename`,
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
revokeDevice(deviceId: string): Promise<void> {
|
||||
return this.http.request<void>(
|
||||
"POST",
|
||||
`/api/devices/${encodeURIComponent(deviceId)}/revoke`,
|
||||
);
|
||||
}
|
||||
|
||||
revokeAllDevices(): Promise<void> {
|
||||
return this.http.request<void>("POST", "/api/devices/revoke-all");
|
||||
}
|
||||
}
|
||||
@ -117,7 +117,25 @@ export class HttpInvoker {
|
||||
* Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
|
||||
* parsed result, or rejects with a {@link GatewayError}.
|
||||
*/
|
||||
async invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one authenticated REST request and maps the response exactly like
|
||||
* {@link invoke} (401 routing + `ErrorDto` preservation).
|
||||
*
|
||||
* The device/auth surface of ticket #77 lives on dedicated routes rather than
|
||||
* the generic RPC, alongside the pre-existing `/api/pair` and `/api/logout` —
|
||||
* the RPC endpoint is itself auth-gated, so the surface that *manages* auth
|
||||
* cannot ride on it. `what` names the call in fallback error messages.
|
||||
*/
|
||||
async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
what = `${method} ${path}`,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
@ -125,10 +143,10 @@ export class HttpInvoker {
|
||||
|
||||
let res: Awaited<ReturnType<FetchLike>>;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, {
|
||||
method: "POST",
|
||||
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify({ command, args }),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
// Same-origin so the HttpOnly session cookie is sent automatically
|
||||
// (ticket #13: no secret in the URL/headers from JS).
|
||||
credentials: "same-origin",
|
||||
@ -136,7 +154,7 @@ export class HttpInvoker {
|
||||
} catch (networkError) {
|
||||
const err: GatewayError = {
|
||||
code: "TRANSPORT_ERROR",
|
||||
message: `HTTP request for '${command}' failed: ${String(networkError)}`,
|
||||
message: `HTTP request for ${what} failed: ${String(networkError)}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
@ -152,16 +170,16 @@ export class HttpInvoker {
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`);
|
||||
throw toGatewayError(parsed, `${what} failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
// A 204 / empty body maps to `undefined` (the void commands).
|
||||
let body: unknown;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
parsed = undefined;
|
||||
}
|
||||
return body as T;
|
||||
return parsed as T;
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { HttpInvoker } from "./httpInvoker";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
import { getWebSession } from "./webSession";
|
||||
import { setWebLiveClient } from "./webLive";
|
||||
import { HttpDeviceGateway } from "./deviceGateway";
|
||||
import {
|
||||
HttpConversationGateway,
|
||||
HttpEmbedderGateway,
|
||||
@ -129,6 +130,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
skill: new HttpSkillGateway(http),
|
||||
memory: new HttpMemoryGateway(http),
|
||||
embedder: new HttpEmbedderGateway(http),
|
||||
device: new HttpDeviceGateway(http),
|
||||
permission: new HttpPermissionGateway(http),
|
||||
workState: new HttpWorkStateGateway(http),
|
||||
conversation: new HttpConversationGateway(http),
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
/**
|
||||
* F2 — the web session: pairing handshake (success marks the flag; wrong code
|
||||
* F2 — the web session: pairing handshake (success marks the flag; a failure
|
||||
* rejects with a clear message), and the 401 → unauthorized transition that
|
||||
* clears the flag and notifies subscribers.
|
||||
*
|
||||
* #77 extends the handshake to `{code, name}` and freezes the two exposed
|
||||
* failure codes. The "no oracle" cases below are a **security** contract, not a
|
||||
* wording preference: if they start distinguishing wrong from expired from
|
||||
* already-used, the API is leaking to an attacker.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
@ -35,35 +40,106 @@ function fetchReturning(status: number, body: unknown): {
|
||||
}
|
||||
|
||||
describe("WebSession pairing", () => {
|
||||
it("POSTs the code to /api/pair and marks the flag on success", async () => {
|
||||
it("POSTs {code, name} 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");
|
||||
await session.pair("AB12CD34", "iPhone");
|
||||
|
||||
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" });
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
|
||||
code: "AB12CD34",
|
||||
name: "iPhone",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong code with a clear message and stays unpaired", async () => {
|
||||
it("maps invalid_or_expired to the single frozen message", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
|
||||
const { fetchImpl } = fetchReturning(401, {
|
||||
code: "invalid_or_expired",
|
||||
message: "whatever the server says",
|
||||
});
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
|
||||
|
||||
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
|
||||
await expect(session.pair("nope", "iPhone")).rejects.toMatchObject({
|
||||
code: "invalidOrExpired",
|
||||
message: "Code invalide ou expiré.",
|
||||
});
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a default message when the server sends no body", async () => {
|
||||
it("maps invalid_name to its own actionable message", async () => {
|
||||
// B3 validates the name *before* consuming the code, so this failure leaves
|
||||
// the code alive — the message must not send the user regenerating one.
|
||||
const { fetchImpl } = fetchReturning(400, {
|
||||
code: "invalid_name",
|
||||
message: "device name must be 40 characters or fewer",
|
||||
});
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("AB12CD34", "x".repeat(60))).rejects.toMatchObject({
|
||||
code: "invalidName",
|
||||
message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not blame the name for an unrelated 400", async () => {
|
||||
// Only the wire code may point at the name; a bare bad request must not send
|
||||
// the user editing a field that was fine.
|
||||
const { fetchImpl } = fetchReturning(400, undefined);
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
|
||||
code: "pairingFailed",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps rate_limited to its own message", async () => {
|
||||
const { fetchImpl } = fetchReturning(429, { code: "rate_limited", message: "slow down" });
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
|
||||
code: "rateLimited",
|
||||
message: "Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
|
||||
});
|
||||
});
|
||||
|
||||
it("never forwards the server's own wording, invalid_name included", async () => {
|
||||
const { fetchImpl } = fetchReturning(400, {
|
||||
code: "invalid_name",
|
||||
message: "device name is required",
|
||||
});
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("AB12CD34", " ")).rejects.toMatchObject({
|
||||
message: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
|
||||
});
|
||||
});
|
||||
|
||||
it("never forwards a server message that could distinguish failure causes", async () => {
|
||||
// Even if a future backend leaks the distinction in its message, the UI must
|
||||
// not repeat it: an attacker learns nothing about *why* a code was refused.
|
||||
const { fetchImpl } = fetchReturning(401, {
|
||||
code: "invalid_or_expired",
|
||||
message: "code already used at 14:02 by device iPhone",
|
||||
});
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("AB12CD34", "iPhone")).rejects.toMatchObject({
|
||||
message: "Code invalide ou expiré.",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the invalid/expired 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.",
|
||||
await expect(session.pair("x", "iPhone")).rejects.toMatchObject({
|
||||
code: "invalidOrExpired",
|
||||
message: "Code invalide ou expiré.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -113,7 +189,7 @@ describe("WebSession default fetch receiver", () => {
|
||||
try {
|
||||
// No `fetchImpl` injected ⇒ the global fallback is used.
|
||||
const session = new WebSession({ baseUrl: "https://host", store: memStore() });
|
||||
await session.pair("4821-93");
|
||||
await session.pair("AB12CD34", "iPhone");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
@ -65,6 +65,63 @@ function defaultBaseUrl(): string {
|
||||
: "http://localhost";
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure codes `POST /api/pair` exposes (#77).
|
||||
*
|
||||
* `invalidOrExpired` deliberately covers wrong, expired, superseded **and
|
||||
* already-used** codes: the API gives an attacker no oracle to tell them apart,
|
||||
* so neither may the UI. Do not add a case here without changing that decision.
|
||||
*
|
||||
* `invalidName` is *not* an exception to that rule: the name is the caller's own
|
||||
* input, so rejecting it reveals nothing about the code. The server validates it
|
||||
* **before** consuming the code (B3), which is what makes the distinction safe
|
||||
* to surface — and what lets the user retry with the same code.
|
||||
*/
|
||||
export type PairingErrorCode =
|
||||
| "invalidOrExpired"
|
||||
| "invalidName"
|
||||
| "rateLimited"
|
||||
| "pairingFailed";
|
||||
|
||||
/** User-facing labels, frozen by UX in the #77 carnet. */
|
||||
const PAIRING_ERROR_MESSAGE: Record<PairingErrorCode, string> = {
|
||||
invalidOrExpired: "Code invalide ou expiré.",
|
||||
// Says which field is wrong and, just as importantly, that the code survived:
|
||||
// without that clause the reflex is to close the panel and burn a new code.
|
||||
invalidName: "Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
|
||||
rateLimited:
|
||||
"Trop de tentatives. Réessayez dans quelques minutes avec un nouveau code.",
|
||||
pairingFailed: "Échec de l'appairage.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps a `/api/pair` failure onto one of the exposed codes.
|
||||
*
|
||||
* The server's own `message` is **not** forwarded: it is mapped through the
|
||||
* frozen labels above so a future backend wording can never reintroduce the
|
||||
* wrong/expired/used distinction in the UI. The wire code wins; the HTTP status
|
||||
* is the fallback for an unparseable body.
|
||||
*/
|
||||
function toPairingError(parsed: unknown, status: number): GatewayError {
|
||||
const wire =
|
||||
parsed && typeof parsed === "object" && "code" in parsed
|
||||
? String((parsed as { code: unknown }).code)
|
||||
: undefined;
|
||||
|
||||
let code: PairingErrorCode;
|
||||
if (wire === "invalid_or_expired") code = "invalidOrExpired";
|
||||
else if (wire === "invalid_name") code = "invalidName";
|
||||
else if (wire === "rate_limited") code = "rateLimited";
|
||||
else if (status === 429) code = "rateLimited";
|
||||
// A bare 400 is not mapped to `invalidName`: only the wire code may claim the
|
||||
// name is at fault, or an unrelated bad request would send the user editing a
|
||||
// field that was fine.
|
||||
else if (status === 401 || status === 403) code = "invalidOrExpired";
|
||||
else code = "pairingFailed";
|
||||
|
||||
return { code, message: PAIRING_ERROR_MESSAGE[code] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The web session state machine. One instance is shared between the pairing UI
|
||||
* and the HTTP invoker (via the module singleton below).
|
||||
@ -118,17 +175,20 @@ export class WebSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Performs the pairing handshake: `POST /api/pair {code, name}` (#77). `name`
|
||||
* is the human label for *this* device, typed on it at pairing time. On success
|
||||
* the server sets the session cookie and this records the paired flag.
|
||||
*
|
||||
* Failures reject with a {@link GatewayError} carrying a user-facing message
|
||||
* from {@link pairingErrorMessage}.
|
||||
*/
|
||||
async pair(code: string): Promise<void> {
|
||||
async pair(code: string, name: 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 }),
|
||||
body: JSON.stringify({ code, name }),
|
||||
});
|
||||
} catch (networkError) {
|
||||
const err: GatewayError = {
|
||||
@ -145,17 +205,7 @@ export class WebSession {
|
||||
} 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;
|
||||
throw toPairingError(parsed, res.status);
|
||||
}
|
||||
|
||||
// Cookie is now set by the server (HttpOnly — not read here); record the flag.
|
||||
|
||||
Reference in New Issue
Block a user