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. safeParse → SealApiError 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. |