Aller au contenu

PD-284 — Agent Developer : Module seal-api (C6)

1. Périmètre

Module seal-api — Client API pour le scellement urgent : - POST /seals/urgent avec payload exact { document_id } - GET /seals/{id}/status pour récupération état initial et resync - Validation Zod stricte sur toutes les réponses - Classe d'erreur typée SealApiError

Fichier produit : src/seal/api-client.ts

2. Implémentation

// src/seal/api-client.ts
// PD-284 — Client API : POST /seals/urgent, GET /seals/{id}/status, validation Zod
// INV-284-13: Aucun endpoint hors POST /seals/urgent, GET /seals/{id}/status, SSE

import { z } from "zod";
import { config } from "../config";
import type { DocumentId, SealId, SealState } from "../types/seal";
import { asSealId, SEAL_STATES, SEAL_VALIDATION_PATTERNS } from "../types/seal";

// =============================================================================
// Constants
// =============================================================================

/** Timeout 30s maximum (cohérent avec API_TIMEOUT existant — INV contract) */
const API_TIMEOUT = 30_000;

// =============================================================================
// SealApiError — Erreur typée pour le module seal-api
// =============================================================================

export type SealApiErrorCode =
  | "NETWORK_ERROR"
  | "TIMEOUT"
  | "HTTP_CLIENT_ERROR"
  | "HTTP_SERVER_ERROR"
  | "INVALID_RESPONSE";

export class SealApiError extends Error {
  readonly code: SealApiErrorCode;
  readonly httpStatus?: number;

  constructor(code: SealApiErrorCode, message: string, httpStatus?: number) {
    super(message);
    this.name = "SealApiError";
    this.code = code;
    this.httpStatus = httpStatus;
  }
}

// =============================================================================
// Zod schemas — Validation réponses API
// =============================================================================

// POST /seals/urgent response: { seal_id: UUID v4 }
const postUrgentSealResponseSchema = z.object({
  seal_id: z.string().regex(SEAL_VALIDATION_PATTERNS.UUID_V4, "seal_id must be UUID v4"),
});

// GET /seals/{id}/status response: état complet avec champs conditionnels par état
// Schéma commun à tous les états
const sealStatusBaseSchema = z.object({
  seal_id: z.string().regex(SEAL_VALIDATION_PATTERNS.UUID_V4),
  status: z.enum(SEAL_STATES),
  timestamp: z.string(),
  document_id: z.string().regex(SEAL_VALIDATION_PATTERNS.UUID_V4),
  degradation_flag: z.enum(["none", "delayed", "critical"]).optional(),
  hash_document: z.string().regex(SEAL_VALIDATION_PATTERNS.HASH_DOCUMENT).optional(),
  tsa_token_ref: z.string().regex(SEAL_VALIDATION_PATTERNS.TSA_TOKEN_REF).optional(),
  tsa_timestamp: z.string().optional(),
  merkle_root: z.string().regex(SEAL_VALIDATION_PATTERNS.MERKLE_ROOT).optional(),
  merkle_proof: z
    .array(z.string().regex(SEAL_VALIDATION_PATTERNS.MERKLE_PROOF_ELEMENT))
    .max(128)
    .optional(),
  blockchain_tx_hash: z
    .string()
    .regex(SEAL_VALIDATION_PATTERNS.BLOCKCHAIN_TX_HASH)
    .optional(),
  proof_package_url: z
    .string()
    .regex(SEAL_VALIDATION_PATTERNS.PROOF_PACKAGE_URL)
    .max(2048)
    .optional(),
  position_in_queue: z.number().int().nonnegative().optional(),
  failure_reason: z.string().optional(),
});

// =============================================================================
// Types dérivés des schemas
// =============================================================================

export type PostUrgentSealResponse = z.infer<typeof postUrgentSealResponseSchema>;
export type SealStatusResponse = z.infer<typeof sealStatusBaseSchema>;

// =============================================================================
// postUrgentSeal — POST /seals/urgent (interface contract C6)
// =============================================================================

/**
 * Déclenche un scellement urgent.
 *
 * Payload exact : `{ document_id }` — aucun champ supplémentaire (INV contract).
 * Validation Zod sur la réponse : `seal_id` UUID v4.
 * Timeout 30s (INV contract).
 *
 * @throws SealApiError avec code approprié
 */
export async function postUrgentSeal(
  documentId: DocumentId,
  authToken: string,
): Promise<SealId> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT);

  let response: Response;
  try {
    response = await fetch(`${config.apiUrl}/seals/urgent`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        Authorization: `Bearer ${authToken}`,
      },
      // Payload exact : { document_id } — forbidden: champs supplémentaires
      body: JSON.stringify({ document_id: documentId }),
      signal: controller.signal,
    });
  } catch (error: unknown) {
    clearTimeout(timeoutId);
    if (error instanceof DOMException && error.name === "AbortError") {
      throw new SealApiError("TIMEOUT", "POST /seals/urgent timed out after 30s");
    }
    const message = error instanceof Error ? error.message : "Network error";
    throw new SealApiError("NETWORK_ERROR", message);
  } finally {
    clearTimeout(timeoutId);
  }

  if (response.status >= 400 && response.status < 500) {
    const body = await safeReadBody(response);
    throw new SealApiError(
      "HTTP_CLIENT_ERROR",
      body ?? `Client error: ${response.status}`,
      response.status,
    );
  }

  if (response.status >= 500) {
    throw new SealApiError(
      "HTTP_SERVER_ERROR",
      `Erreur serveur, réessayez`,
      response.status,
    );
  }

  if (!response.ok) {
    throw new SealApiError("NETWORK_ERROR", `Unexpected status: ${response.status}`);
  }

  let payload: unknown;
  try {
    payload = await response.json();
  } catch {
    throw new SealApiError("INVALID_RESPONSE", "Invalid JSON response from POST /seals/urgent");
  }

  const parsed = postUrgentSealResponseSchema.safeParse(payload);
  if (!parsed.success) {
    const issue = parsed.error.issues[0];
    throw new SealApiError(
      "INVALID_RESPONSE",
      `Response validation failed: ${issue?.path?.join(".") ?? "unknown"}${issue?.message ?? ""}`,
    );
  }

  return asSealId(parsed.data.seal_id);
}

// =============================================================================
// getSealStatus — GET /seals/{id}/status (interface contract C6)
// =============================================================================

/**
 * Récupère l'état courant d'un scellement.
 *
 * Validation Zod sur la réponse : état + champs par état (§5.12).
 * Timeout 30s (INV contract).
 *
 * @throws SealApiError avec code approprié
 */
export async function getSealStatus(
  sealId: SealId,
  authToken: string,
): Promise<SealStatusResponse> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT);

  let response: Response;
  try {
    response = await fetch(`${config.apiUrl}/seals/${sealId}/status`, {
      method: "GET",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${authToken}`,
      },
      signal: controller.signal,
    });
  } catch (error: unknown) {
    clearTimeout(timeoutId);
    if (error instanceof DOMException && error.name === "AbortError") {
      throw new SealApiError("TIMEOUT", "GET /seals/{id}/status timed out after 30s");
    }
    const message = error instanceof Error ? error.message : "Network error";
    throw new SealApiError("NETWORK_ERROR", message);
  } finally {
    clearTimeout(timeoutId);
  }

  if (response.status >= 400 && response.status < 500) {
    const body = await safeReadBody(response);
    throw new SealApiError(
      "HTTP_CLIENT_ERROR",
      body ?? `Client error: ${response.status}`,
      response.status,
    );
  }

  if (response.status >= 500) {
    throw new SealApiError(
      "HTTP_SERVER_ERROR",
      `Erreur serveur, réessayez`,
      response.status,
    );
  }

  if (!response.ok) {
    throw new SealApiError("NETWORK_ERROR", `Unexpected status: ${response.status}`);
  }

  let payload: unknown;
  try {
    payload = await response.json();
  } catch {
    throw new SealApiError("INVALID_RESPONSE", "Invalid JSON response from GET /seals/{id}/status");
  }

  const parsed = sealStatusBaseSchema.safeParse(payload);
  if (!parsed.success) {
    const issue = parsed.error.issues[0];
    throw new SealApiError(
      "INVALID_RESPONSE",
      `Status response validation failed: ${issue?.path?.join(".") ?? "unknown"}${issue?.message ?? ""}`,
    );
  }

  return parsed.data;
}

// =============================================================================
// Helpers internes
// =============================================================================

/**
 * Lecture sécurisée du body d'erreur (ne lance jamais).
 * Forbidden: logger le payload contenant tokens ou secrets.
 */
async function safeReadBody(response: Response): Promise<string | null> {
  try {
    const text = await response.text();
    // Limite taille pour éviter log excessif, et ne jamais logger de tokens
    if (text.length > 500) {
      return text.slice(0, 500);
    }
    return text || null;
  } catch {
    return null;
  }
}

3. Tests contractuels

// src/seal/__tests__/api-client.test.ts

import { postUrgentSeal, getSealStatus, SealApiError } from "../api-client";
import type { DocumentId, SealId } from "../../types/seal";
import { asDocumentId, asSealId } from "../../types/seal";

// Mock config
jest.mock("../../config", () => ({
  config: { apiUrl: "https://api.test.com/v1" },
}));

// Mock fetch
const mockFetch = jest.fn();
global.fetch = mockFetch;

const TEST_DOC_ID = asDocumentId("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
const TEST_SEAL_ID = asSealId("11111111-2222-3333-4444-555555555555");
const TEST_AUTH = "test-token";

function jsonResponse(body: unknown, status = 200): Response {
  return {
    ok: status >= 200 && status < 300,
    status,
    json: async () => body,
    text: async () => JSON.stringify(body),
    headers: new Headers(),
  } as Response;
}

beforeEach(() => {
  mockFetch.mockReset();
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers();
});

describe("postUrgentSeal", () => {
  // TC-NOM-04 (partiel) — POST /seals/urgent succès
  it("returns branded SealId on success", async () => {
    mockFetch.mockResolvedValueOnce(
      jsonResponse({ seal_id: TEST_SEAL_ID as string }),
    );

    const result = await postUrgentSeal(TEST_DOC_ID, TEST_AUTH);
    expect(result).toBe(TEST_SEAL_ID);

    // Verify exact payload: { document_id } only — no extra fields
    const [url, options] = mockFetch.mock.calls[0];
    expect(url).toBe("https://api.test.com/v1/seals/urgent");
    expect(options.method).toBe("POST");
    const body = JSON.parse(options.body);
    expect(Object.keys(body)).toEqual(["document_id"]);
    expect(body.document_id).toBe(TEST_DOC_ID);
  });

  // TC-ERR-01 — POST urgent rejeté 4xx
  it("throws HTTP_CLIENT_ERROR on 4xx", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse({ error: "quota" }, 422));

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toThrow(SealApiError);
    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "HTTP_CLIENT_ERROR",
    });
  });

  // TC-ERR-02 — POST urgent rejeté 5xx
  it("throws HTTP_SERVER_ERROR on 5xx", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse(null, 500));

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toThrow(SealApiError);
    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "HTTP_SERVER_ERROR",
    });
  });

  // Network error
  it("throws NETWORK_ERROR on fetch failure", async () => {
    mockFetch.mockRejectedValueOnce(new Error("Network unreachable"));

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "NETWORK_ERROR",
    });
  });

  // Timeout
  it("throws TIMEOUT on AbortError", async () => {
    const abortError = new DOMException("Aborted", "AbortError");
    mockFetch.mockRejectedValueOnce(abortError);

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "TIMEOUT",
    });
  });

  // Invalid response — seal_id not UUID
  it("throws INVALID_RESPONSE if seal_id is not UUID v4", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse({ seal_id: "not-a-uuid" }));

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "INVALID_RESPONSE",
    });
  });

  // Invalid JSON
  it("throws INVALID_RESPONSE on invalid JSON body", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      status: 200,
      json: async () => { throw new Error("parse error"); },
      text: async () => "not json",
      headers: new Headers(),
    } as Response);

    await expect(postUrgentSeal(TEST_DOC_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "INVALID_RESPONSE",
    });
  });

  // INV-284-13: No extra fields in payload
  it("sends exactly { document_id } — no extra fields", async () => {
    mockFetch.mockResolvedValueOnce(
      jsonResponse({ seal_id: TEST_SEAL_ID as string }),
    );

    await postUrgentSeal(TEST_DOC_ID, TEST_AUTH);
    const body = JSON.parse(mockFetch.mock.calls[0][1].body);
    expect(Object.keys(body)).toHaveLength(1);
    expect(body).toHaveProperty("document_id");
  });

  // Auth header present
  it("includes Authorization Bearer header", async () => {
    mockFetch.mockResolvedValueOnce(
      jsonResponse({ seal_id: TEST_SEAL_ID as string }),
    );

    await postUrgentSeal(TEST_DOC_ID, TEST_AUTH);
    expect(mockFetch.mock.calls[0][1].headers.Authorization).toBe("Bearer test-token");
  });
});

describe("getSealStatus", () => {
  const validStatus = {
    seal_id: TEST_SEAL_ID as string,
    status: "RECEIVED" as const,
    timestamp: "2026-03-13T10:00:00Z",
    document_id: TEST_DOC_ID as string,
    hash_document: "a".repeat(64),
  };

  // TC-NOM-04 (partiel) — GET /seals/{id}/status succès
  it("returns validated SealStatusResponse", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse(validStatus));

    const result = await getSealStatus(TEST_SEAL_ID, TEST_AUTH);
    expect(result.status).toBe("RECEIVED");
    expect(result.seal_id).toBe(TEST_SEAL_ID);
  });

  it("calls correct URL with seal_id", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse(validStatus));

    await getSealStatus(TEST_SEAL_ID, TEST_AUTH);
    expect(mockFetch.mock.calls[0][0]).toBe(
      `https://api.test.com/v1/seals/${TEST_SEAL_ID}/status`,
    );
  });

  // Validation Zod — invalid status enum
  it("throws INVALID_RESPONSE if status is not a valid SealState", async () => {
    mockFetch.mockResolvedValueOnce(
      jsonResponse({ ...validStatus, status: "UNKNOWN_STATE" }),
    );

    await expect(getSealStatus(TEST_SEAL_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "INVALID_RESPONSE",
    });
  });

  // Validation Zod — invalid seal_id format
  it("throws INVALID_RESPONSE if seal_id is not UUID", async () => {
    mockFetch.mockResolvedValueOnce(
      jsonResponse({ ...validStatus, seal_id: "bad" }),
    );

    await expect(getSealStatus(TEST_SEAL_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "INVALID_RESPONSE",
    });
  });

  // 5xx
  it("throws HTTP_SERVER_ERROR on 500", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse(null, 500));

    await expect(getSealStatus(TEST_SEAL_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "HTTP_SERVER_ERROR",
    });
  });

  // 404
  it("throws HTTP_CLIENT_ERROR on 404", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse({ error: "not found" }, 404));

    await expect(getSealStatus(TEST_SEAL_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "HTTP_CLIENT_ERROR",
      httpStatus: 404,
    });
  });

  // Network error
  it("throws NETWORK_ERROR on fetch failure", async () => {
    mockFetch.mockRejectedValueOnce(new Error("DNS resolution failed"));

    await expect(getSealStatus(TEST_SEAL_ID, TEST_AUTH)).rejects.toMatchObject({
      code: "NETWORK_ERROR",
    });
  });

  // Validates degradation_flag enum
  it("accepts valid degradation_flag values", async () => {
    for (const flag of ["none", "delayed", "critical"] as const) {
      mockFetch.mockResolvedValueOnce(
        jsonResponse({ ...validStatus, degradation_flag: flag }),
      );
      const result = await getSealStatus(TEST_SEAL_ID, TEST_AUTH);
      expect(result.degradation_flag).toBe(flag);
    }
  });

  // Validates SEALED state with all artifacts
  it("validates SEALED response with all proof artifacts", async () => {
    const sealedStatus = {
      seal_id: TEST_SEAL_ID as string,
      status: "SEALED",
      timestamp: "2026-03-13T10:05:00Z",
      document_id: TEST_DOC_ID as string,
      hash_document: "b".repeat(64),
      merkle_root: "c".repeat(64),
      merkle_proof: ["d".repeat(64), "e".repeat(64)],
      blockchain_tx_hash: "0x" + "f".repeat(64),
      proof_package_url: "https://storage.probatiovault.com/proof/123",
    };
    mockFetch.mockResolvedValueOnce(jsonResponse(sealedStatus));

    const result = await getSealStatus(TEST_SEAL_ID, TEST_AUTH);
    expect(result.status).toBe("SEALED");
    expect(result.blockchain_tx_hash).toBe(sealedStatus.blockchain_tx_hash);
  });

  // INV-284-13 — only GET /seals/{id}/status, no other endpoint
  it("uses GET method", async () => {
    mockFetch.mockResolvedValueOnce(jsonResponse(validStatus));

    await getSealStatus(TEST_SEAL_ID, TEST_AUTH);
    expect(mockFetch.mock.calls[0][1].method).toBe("GET");
  });
});

4. Matrice de couverture

Test-ID Fichier test Ligne approx. Commentaire
TC-NOM-04 (partiel) api-client.test.ts postUrgentSeal success + getSealStatus success Séquence POST→GET vérifiée (ordre dans C7)
TC-ERR-01 api-client.test.ts POST 4xx HTTP_CLIENT_ERROR
TC-ERR-02 api-client.test.ts POST 5xx HTTP_SERVER_ERROR
TC-INV-10 (partiel) api-client.test.ts payload exact INV-284-13
NON-CONTRACTUAL api-client.test.ts timeout, network error, invalid JSON Couverture robustesse
NON-CONTRACTUAL api-client.test.ts Zod validation edge cases Invalid seal_id, unknown state

5. Décisions architecturales

architectural_decisions:
  - decision: "Schéma Zod unique souple pour GET /seals/{id}/status au lieu de schemas discriminés par état"
    rationale: "Le GET status retourne un snapshot complet. Les champs additionnels sont optionnels. La validation discriminée par état est la responsabilité du event-processor (C4) qui consomme les événements SSE. Le client API valide la structure, pas la sémantique par état."
    alternatives_considered:
      - "z.discriminatedUnion sur status  plus strict mais fragile si le backend ajoute un champ optionnel"
      - "Pas de validation Zod  interdit par invariant contract"
    trade_offs:
      - "Plus permissif sur les champs conditionnels par état en échange d'une résilience aux changements backend mineurs"

  - decision: "SealApiError avec code enum discriminant au lieu de sous-classes d'erreur"
    rationale: "Pattern cohérent avec ExportError existant dans PD-283. Un seul type d'erreur avec code discriminant simplifie le catch dans l'orchestrateur (C7)."
    alternatives_considered:
      - "Sous-classes (NetworkError, TimeoutError, ValidationError)  plus OOP mais plus verbeux"
    trade_offs:
      - "switch(error.code) au lieu de instanceof  acceptable pour 5 codes"

6. Hypothèses documentées

ID Hypothèse Impact si faux
H-API-01 Le backend PD-80 expose POST /seals/urgent et GET /seals/{id}/status conformément au contrat Module inutilisable — STUB avec mocks en attendant PD-80 DONE
H-API-02 Le token d'authentification est un Bearer JWT géré par le module auth existant Si autre mécanisme (cookie, API key), modifier les headers
H-API-03 config.apiUrl inclut le préfixe de version (/v1) Si non, les URLs construites seront incorrectes

7. Vérification des invariants

Invariant Statut Mécanisme
INV-284-13 : Aucun endpoint hors POST /seals/urgent, GET /seals/{id}/status, SSE CONFORME Le module expose exactement 2 fonctions (postUrgentSeal, getSealStatus). Aucun autre endpoint appelé.
Payload POST exact { document_id } CONFORME JSON.stringify({ document_id: documentId }) — un seul champ, pas d'extra. Test dédié vérifie Object.keys(body).length === 1.
Validation Zod sur réponse POST (seal_id UUID v4) CONFORME postUrgentSealResponseSchema avec regex UUID v4. safeParseSealApiError si invalide.
Validation Zod sur réponse GET status (état + champs §5.12) CONFORME sealStatusBaseSchema valide status enum, formats regex, tailles.
Timeout 30s maximum CONFORME AbortController avec setTimeout(30_000). AbortError → SealApiError("TIMEOUT").
Forbidden : logger payload contenant tokens/secrets CONFORME safeReadBody tronque à 500 chars, utilisé uniquement pour les messages d'erreur HTTP (pas le payload de succès). Aucun log du body de succès.
Forbidden : accepter réponse sans validation Zod CONFORME Les deux fonctions passent par safeParse — aucun chemin de succès sans validation.
Forbidden : ajouter endpoint non contractualisé CONFORME Seuls POST /seals/urgent et GET /seals/{id}/status sont implémentés.