All files / src/lib eudi-store.ts

90.9% Statements 20/22
50% Branches 2/4
100% Functions 7/7
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98                                38x                                                     38x 38x       24x 24x 46x           11x         8x             9x 9x         9x 9x       15x 15x             6x 6x 6x 6x 6x       1x    
/**
 * Short-TTL, in-memory store for in-flight EUDI Wallet OpenID4VP login
 * transactions, keyed by an opaque server-issued session id (`sid`).
 *
 * The `sid` is the only handle the browser ever sees — the verifier-side
 * transaction id, the nonce, and (once verified) the resolved patient identity
 * never leave the server. The browser polls `GET /api/auth/eudi/status?sid=…`
 * and, on completion, calls `signIn("eudi-wallet", { sid })`; the Credentials
 * provider re-reads the pinned, server-verified result for that `sid`.
 *
 * Persistence: an in-memory Map is acceptable because the Azure Container Apps
 * UI runs a single replica (min=max=1, see scripts/azure/05-cfm-ui.sh). It does
 * NOT survive a revision rollover/restart — in-flight logins would drop. A
 * durable store (Neo4j / Vault / Postgres) is the production path. Flagged in
 * ADR-028.
 */
import { randomUUID, randomBytes } from "crypto";
 
export type EudiTxStatus = "pending" | "completed" | "error";
 
export interface EudiVerifiedPatient {
  /** username key matching DEMO_PERSONAS / PATIENT_RESOURCE_MAP (e.g. "patient1") */
  username: string;
  /** display name — the verified wallet holder's name when available */
  displayName: string;
  roles: string[];
}
 
export interface EudiTransaction {
  sid: string;
  /** verifier-side transaction / presentation id */
  transactionId: string;
  /** anti-replay nonce echoed by the wallet's presentation */
  nonce: string;
  status: EudiTxStatus;
  createdAt: number;
  /** pinned only after the verifier confirms a valid presentation */
  verifiedPatient?: EudiVerifiedPatient;
  /** true once a session has been minted from this sid (single-use) */
  consumed?: boolean;
  error?: string;
}
 
const TTL_MS = 5 * 60 * 1000;
const store = new Map<string, EudiTransaction>();
 
/** Remove expired transactions so the Map can't grow unbounded. */
function sweep(): void {
  const cutoff = Date.now() - TTL_MS;
  for (const [sid, tx] of store) {
    Iif (tx.createdAt < cutoff) store.delete(sid);
  }
}
 
/** Opaque browser-facing id. */
export function newSid(): string {
  return randomUUID();
}
 
/** Anti-replay nonce for the OpenID4VP request. */
export function newNonce(): string {
  return randomBytes(24).toString("base64url");
}
 
export function putTransaction(
  tx: Omit<EudiTransaction, "status" | "createdAt"> &
    Partial<Pick<EudiTransaction, "status" | "createdAt">>,
): EudiTransaction {
  sweep();
  const full: EudiTransaction = {
    status: "pending",
    createdAt: Date.now(),
    ...tx,
  };
  store.set(full.sid, full);
  return full;
}
 
export function getTransaction(sid: string): EudiTransaction | undefined {
  sweep();
  return store.get(sid);
}
 
export function updateTransaction(
  sid: string,
  patch: Partial<EudiTransaction>,
): EudiTransaction | undefined {
  const tx = store.get(sid);
  Iif (!tx) return undefined;
  const next = { ...tx, ...patch };
  store.set(sid, next);
  return next;
}
 
export function deleteTransaction(sid: string): void {
  store.delete(sid);
}