/** * HTTP client adapter for `DBIS/cc-identity-core`. * * Provider-switched: when `CC_IDENTITY_URL` is set the client makes * real HTTP calls to the upstream Complete Credential identity * service; otherwise every method returns a deterministic mock so * unit tests, local dev, and CI still work. * * Upstream surface (openapi.yaml + src/server.mjs at recon time): * GET /health * GET /ready * POST /v1/subjects * * Extend as additional endpoints ship upstream. */ import { randomUUID } from "crypto"; import { logger } from "../../logging/logger"; import type { CcHealthStatus, CcSubject, CcSubjectCreate, } from "./types"; export interface CcIdentityConfig { baseUrl?: string; apiKey?: string; timeoutMs?: number; fetchImpl?: typeof fetch; } export interface CcIdentityClient { mode: "live" | "mock"; health(): Promise; ready(): Promise; createSubject(req: CcSubjectCreate, correlationId?: string): Promise; } function loadConfigFromEnv(): CcIdentityConfig { return { baseUrl: process.env.CC_IDENTITY_URL, apiKey: process.env.CC_IDENTITY_API_KEY, timeoutMs: process.env.CC_IDENTITY_TIMEOUT_MS ? parseInt(process.env.CC_IDENTITY_TIMEOUT_MS, 10) : 10_000, }; } class HttpCcIdentityClient implements CcIdentityClient { readonly mode = "live" as const; private readonly baseUrl: string; private readonly apiKey?: string; private readonly timeoutMs: number; private readonly fetchImpl: typeof fetch; constructor( cfg: Required> & CcIdentityConfig, ) { this.baseUrl = cfg.baseUrl.replace(/\/+$/, ""); this.apiKey = cfg.apiKey; this.timeoutMs = cfg.timeoutMs ?? 10_000; this.fetchImpl = cfg.fetchImpl ?? fetch; } private async request( method: "GET" | "POST", path: string, body?: unknown, correlationId?: string, ): Promise { const url = `${this.baseUrl}${path}`; const headers: Record = { Accept: "application/json" }; if (body !== undefined) headers["Content-Type"] = "application/json"; if (this.apiKey) headers["X-API-Key"] = this.apiKey; if (correlationId) headers["X-Correlation-Id"] = correlationId; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { const resp = await this.fetchImpl(url, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!resp.ok) { const text = await resp.text().catch(() => ""); throw new Error( `cc-identity ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`, ); } return (await resp.json()) as T; } finally { clearTimeout(timer); } } health(): Promise { return this.request("GET", "/health"); } ready(): Promise { return this.request("GET", "/ready"); } createSubject( req: CcSubjectCreate, correlationId?: string, ): Promise { return this.request( "POST", "/v1/subjects", req, correlationId ?? randomUUID(), ); } } class MockCcIdentityClient implements CcIdentityClient { readonly mode = "mock" as const; async health(): Promise { return { status: "ok", service: "cc-identity-core" }; } async ready(): Promise { return { status: "ok", service: "cc-identity-core", persistence: false }; } async createSubject(req: CcSubjectCreate): Promise { return { subjectId: randomUUID(), tenantId: req.tenantId ?? "tenant-demo", entityId: req.entityId ?? "entity-demo", createdAt: new Date().toISOString(), }; } } export function createCcIdentityClient( cfg: CcIdentityConfig = loadConfigFromEnv(), ): CcIdentityClient { if (cfg.baseUrl) { logger.info( { baseUrl: cfg.baseUrl, mode: "live" }, "[CcIdentity] HTTP client", ); return new HttpCcIdentityClient({ ...cfg, baseUrl: cfg.baseUrl }); } logger.info( { mode: "mock" }, "[CcIdentity] HTTP client (no CC_IDENTITY_URL — mock mode; upstream cc-identity-core ships code but not yet deployed)", ); return new MockCcIdentityClient(); }