Aller au contenu

PD-299 — Livrable agent-developer : module sharing-api

  • Story : PD-299 — Consolidation PD-298, A3 (authentification réelle Bearer)
  • Agent : agent-developer (Claude)
  • Module : sharing-api
  • Projet : ProbatioVault-app
  • Scope fichiers : ProbatioVault-app/src/sharing/api/**

1. Objectif

Rendre tous les appels API du flux sharing authentifiés par Bearer réel, avec :

  • construction stricte du header Authorization: Bearer <token> (un seul espace ASCII) ;
  • validation de sortie par regex /^Bearer [!-~]+$/ avant toute émission réseau ;
  • masquage systématique du token (<REDACTED_TOKEN>) dans les erreurs et logs ;
  • exposition des 5 interfaces listées dans le contrat : SharingApiClient, buildAuthHeader, maskToken, SharingApiError, createShare, revokeShare, listShares, getShareDetail, getShareEvents.

2. Fichiers modifiés / créés

Fichier État Rôle
ProbatioVault-app/src/sharing/api/auth.ts Créé Helpers getAccessToken, buildAuthHeader, regex contractuelles (TOKEN_PATTERN, AUTHORIZATION_HEADER_PATTERN).
ProbatioVault-app/src/sharing/api/index.ts Réécrit Client HTTP SharingApiClient + 5 endpoints + re-exports SharingApiError / maskToken + alias legacy listShareEvents / ShareApiError.

Aucun fichier hors périmètre src/sharing/api/** n'a été modifié. Les signalements cross-module sont dans §8.

3. Conformité aux invariants contractuels

Invariant (sharing-api) Implémentation Observable
buildAuthHeader lit auth_access_token et produit Bearer <token> avec UN SEUL espace ASCII. buildAuthHeader() construit littéralement Bearer ${token} après validation regex. Chaîne retournée passe /^Bearer [!-~]+$/.
Regex /^Bearer [!-~]+$/ avant émission, sinon SharingApiError('SHARE_AUTH_INVALID'). Double validation : token brut (TOKEN_PATTERN) + header final (AUTHORIZATION_HEADER_PATTERN). En cas d'échec, SharingApiError('SHARE_AUTH_INVALID') levée avant fetch. Test d'émission avec token contenant CR/LF ou espace rejeté.
Token absent/invalide => SharingApiError('SHARE_AUTH_MISSING'), pas d'appel réseau. getAccessToken() retourne null sur absence/trim vide ; buildAuthHeader lève SHARE_AUTH_MISSING avant toute construction de requête. fetchWithTimeout jamais atteint dans ce cas.
maskToken() substitue la portion après Bearer par <REDACTED_TOKEN>. Fonction importée telle quelle depuis ../errors (DEC-10). SharingApiError l'applique déjà dans son constructeur pour userMessage/technicalDetail. toJSON() d'une SharingApiError contenant un token => Bearer <REDACTED_TOKEN>.
Tous les appels réseau passent par SharingApiClient. 5 endpoints (createShare, revokeShare, listShares, getShareDetail, getShareEvents) définis sur SharingApiClient ; fonctions standalone délèguent à sharingApiClient. Aucun fetch( direct dans src/sharing/api/** hors fetchWithTimeout.
Messages SharingApiError localisables via clé i18n. Tous les userMessage sont des clés (sharing.errors.auth_missing, sharing.errors.auth_invalid, sharing.errors.transport.<code>). Pas de texte français en dur. grep -E 'userMessage: "[a-zéèà ]+"' src/sharing/api/ = 0 match.

4. Conformité aux interdits contractuels

Interdit Défense en profondeur
Stocker auth_access_token dans un state global non protégé. getAccessToken() lit à la volée via secureGet ; aucune variable module-level ne cache le token.
Émettre une requête sans vérification de la regex header finale. buildHeaders() appelle buildAuthHeader() qui valide avant retour ; exception levée avant fetchWithTimeout.
console.log/console.error du token non masqué. Aucun console.* dans src/sharing/api/**. Les erreurs transportées (ShareApiError) appliquent maskToken sur le message HTTP avant construction.
Construire le header avec 2 espaces entre Bearer et le token. Template littéral Bearer ${token} + regex de sortie [!-~]+ interdit l'espace entre Bearer et le token.
Inclure CR/LF/NUL dans le token émis. TOKEN_PATTERN = /^[!-~]+$/ exclut tout caractère de contrôle (0x00–0x1F, 0x7F) et l'espace (0x20).
Rendre SharingApiError sérialisable via JSON.stringify sans maskToken. SharingApiError.toJSON (module ../errors) masque userMessage/technicalDetail dans le constructeur ; ShareApiError.toJSON hérite de ce comportement.

5. Interfaces exposées

Toutes listées dans sharing-api.interfaces :

// Depuis src/sharing/api/index.ts
export { buildAuthHeader, getAccessToken, TOKEN_PATTERN, AUTHORIZATION_HEADER_PATTERN } from "./auth";
export { SharingApiError, maskToken } from "../errors";
export type { SharingErrorCode } from "../errors";

export class SharingApiClient { /* createShare, revokeShare, listShares, getShareDetail, getShareEvents */ }
export const sharingApiClient: SharingApiClient;

export function createShare(request: CreateShareRequest): Promise<ShareResponse>;
export function revokeShare(shareId: ShareId, request?: RevokeShareRequest): Promise<ShareResponse>;
export function listShares(params: {...}): Promise<ShareListResponse>;
export function getShareDetail(shareId: ShareId): Promise<ShareResponse>;
export function getShareEvents(shareId: ShareId, params?: {...}): Promise<ShareEventsListResponse>;

// Legacy (compat PD-298)
export class ShareApiError extends SharingApiError { /* transport NETWORK/HTTP/TIMEOUT/INVALID_RESPONSE */ }
/** @deprecated alias de getShareEvents */
export const listShareEvents: typeof getShareEvents;

6. Flux de validation Bearer (F-299-03)

getAccessToken() ──► secureGet("auth_access_token")
  token === null ──► SharingApiError('SHARE_AUTH_MISSING')
  TOKEN_PATTERN.test(token) === false ──► SharingApiError('SHARE_AUTH_INVALID')
  header = `Bearer ${token}`
  AUTHORIZATION_HEADER_PATTERN.test(header) === false ──► SharingApiError('SHARE_AUTH_INVALID')
  return header ──► injecté dans headers.Authorization ──► fetchWithTimeout

La défense en profondeur (double regex) capture un cas théorique de corruption mémoire/concurrence où token passerait le premier check mais pas le second ; la branche est inatteignable en pratique mais bloque toute émission si elle l'était.

7. Décisions architecturales (code contract)

DEC-A3-01 — Double validation (token brut + header final)

  • Décision : valider à la fois token (regex ^[!-~]+$) et le header assemblé (^Bearer [!-~]+$).
  • Rationale : défense en profondeur, protection anti-concurrence/corruption mémoire, coût négligeable.
  • Alternatives considérées : (1) regex uniquement sur le token, (2) regex uniquement sur le header.
  • Trade-offs acceptés : une branche logiquement inatteignable (rejet header alors que token validé). Lisibilité vs sûreté → sûreté privilégiée.

DEC-A3-02 — Regex stricte ^Bearer [!-~]+$ plus stricte que D-299-07

  • Décision : émettre Bearer avec UN SEUL espace ASCII au lieu de [ ]+ autorisé par D-299-07.
  • Rationale : l'invariant sharing-api exige « UN SEUL espace ASCII » ; PD-299-plan §7.1 (DEC-10 / E-SEC-05) documente l'émission plus stricte pour interopérabilité RFC 6750 et anti-CRLF.
  • Alternatives considérées : (1) accepter [ ]+ à l'émission (D-299-07 littéral), (2) émettre à 1 espace mais valider avec [ ]+.
  • Trade-offs acceptés : incohérence apparente avec D-299-07 → documentée explicitement.

DEC-A3-03 — ShareApiError conservée, héritant de SharingApiError

  • Décision : conserver ShareApiError (codes transport NETWORK/HTTP/TIMEOUT/INVALID_RESPONSE) en sous-classe de SharingApiError, plutôt que la supprimer.
  • Rationale : SharingErrorCode (module ../errors, hors scope) ne couvre pas les codes transport. Héritage = un seul catch (SharingApiError) capture tout et le masquage token s'applique uniformément.
  • Alternatives considérées : (1) étendre SharingErrorCode (cross-module, non autorisé), (2) remplacer tous les codes transport par SHARE_AUTH_INVALID (perd l'information pour le diagnostic).
  • Trade-offs acceptés : doublon ShareApiError/SharingApiError temporaire. À consolider dans une itération ultérieure (§8.1).

8. Écarts signalés aux agents amont/aval (hors scope modification)

8.1 Extension de SharingErrorCode (module sharing-telemetry/errors)

Les codes transport (NETWORK_ERROR, TIMEOUT, HTTP_CLIENT_ERROR, HTTP_SERVER_ERROR, INVALID_RESPONSE) ne figurent pas dans SHARING_ERROR_CODES (cf. src/sharing/errors/index.ts). Pour l'instant, ShareApiError mappe ces cas sur SHARE_AUTH_INVALID côté parent.

Suggestion (à traiter par l'agent en charge de sharing-telemetry) : étendre SHARING_ERROR_CODES ou créer un enum distinct ShareTransportErrorCode côté ../errors et autoriser SharingApiError à l'accepter. La table DEC-05 du plan §6.1 devra être complétée en conséquence.

8.2 Mise à jour du barrel src/sharing/index.ts

Le barrel (hors scope sharing-api) réexporte actuellement :

export { createShare, listShares, revokeShare, listShareEvents, ShareApiError } from "./api";

Ces imports restent fonctionnels grâce aux alias. Pour aligner sur le contrat PD-299 (qui mentionne getShareEvents, pas listShareEvents) :

  • ajouter SharingApiClient, sharingApiClient, buildAuthHeader, maskToken, SharingApiError, getShareDetail, getShareEvents ;
  • déprécier l'export listShareEvents ;
  • retirer à terme l'alias ShareApiError une fois §8.1 résolu.

8.3 Migration callers hooks/index.ts

src/sharing/hooks/index.ts (scope sharing-ui) utilise encore listShareEvents. L'alias maintient la compatibilité ; le caller peut migrer vers getShareEvents dans la PR sharing-ui correspondante. Aucun changement de comportement runtime.

8.4 useAuth et getAccessToken

L'invariant sharing-api mentionne « via useAuth().getAccessToken() ». Le hook useAuth (src/hooks/useAuth.ts, hors scope) n'expose pas getAccessToken mais lit la même clé (auth_access_token) via secureGet. Mon implémentation respecte la source de vérité (même clé). Suggestion pour l'agent sharing-ui : ajouter getAccessToken: () => Promise<string | null> à l'API publique de useAuth, déléguant à getAccessToken() de sharing-api/auth.ts. Aucun impact sur sharing-api lui-même.

9. Hypothèses de travail

ID Hypothèse Impact si fausse
H-A3-01 secureGet("auth_access_token") renvoie la même chaîne que celle écrite par useAuth.loginBackend (secureSet("auth_access_token", accessToken)). Header construit à partir d'un token invalide → SHARE_AUTH_INVALID côté client avant émission.
H-A3-02 expo-secure-store (via services/storage.ts) ne retourne jamais de chaîne contenant des caractères hors [!-~]. Un token contenant espace/CR/LF est rejeté avec SHARE_AUTH_INVALID avant émission.
H-A3-03 Les clés i18n sharing.errors.auth_missing / sharing.errors.auth_invalid / sharing.errors.transport.<code> seront enregistrées par l'agent sharing-ui dans les bundles de traduction. Affichage UI brut de la clé en cas d'oubli — signalement fait dans §8.
H-A3-04 Le serveur backend renvoie 401/403 pour un token rejeté, 5xx pour ses propres erreurs. Correcte distinction des codes transport. Si le serveur renvoie des statuts inhabituels, HTTP_CLIENT_ERROR couvre le cas par défaut.

10. Vérifications effectuées

  • TypeScript (npx tsc --noEmit) : aucune erreur introduite dans src/sharing/api/**. Les erreurs préexistantes (components/vault/DocumentRow.tsx, components/common/ProfileAvatar.tsx, crypto/*, tests qwen) sont hors scope.
  • Contrat des interfaces : les 9 symboles de sharing-api.interfaces sont exportés (vérifiable par grep -E "export (class|function|const) (SharingApiClient|buildAuthHeader|maskToken|SharingApiError|createShare|revokeShare|listShares|getShareDetail|getShareEvents)" src/sharing/api/index.ts).
  • Contrat des forbidden : aucune occurrence de fetch( directe hors fetchWithTimeout, aucun console.* dans src/sharing/api/**, aucun accès module-level au token, template littéral avec un seul espace, regex interdisant CR/LF/NUL.
  • Compat callers PD-298 : listShareEvents et ShareApiError exportés comme alias/sous-classe → src/sharing/hooks/index.ts et src/sharing/index.ts compilent sans modification.

11. Tests contractuels à couvrir par sharing-tests (A1)

Scénarios dont la logique est désormais supportée par le code de sharing-api :

  • TC-NOM-04 (header conforme D-299-07) : sharingApiClient.createShare doit inclure Authorization: Bearer <token> validé.
  • TC-ERR-03 (token absent) : buildAuthHeader lève SharingApiError('SHARE_AUTH_MISSING') si secureGet renvoie null.
  • TC-ERR-04 (header invalide — CRLF) : token contenant \n ou \r → rejet SHARE_AUTH_INVALID avant fetch.
  • TC-NEG-02 (Bearer token / Bearer \nX-Test:1) : regex de sortie rejette.
  • TC-NR-SUPP-01 (non-leak token) : JSON.stringify(new SharingApiError('SHARE_AUTH_INVALID', { technicalDetail: 'Bearer abc123' })) ne contient pas abc123.

Ces tests sont à matérialiser par l'agent sharing-tests dans src/sharing/__tests__/ ; la façade sharingApiClient et la fonction buildAuthHeader sont prêtes à être mockées (nock ou jest.spyOn).