import type { StorageProvider } from '@shade/core'; import { ConfigurationError } from '@shade/core'; import type { ObservabilityHook } from '@shade/observability'; /** * Shade SDK configuration. * * All fields have sane defaults except `prekeyServer` which is required * for any networked usage. */ export interface ShadeConfig { /** Required: base URL of the prekey server */ prekeyServer: string; /** * Storage backend. Accepts: * - "memory" — in-memory only (lost on restart, good for tests) * - "sqlite:/path/to/file.db" — SQLite backend * - { type: 'postgres', url: 'postgres://...' } — PostgreSQL backend * - An explicit StorageProvider instance * * Default: "memory" */ storage?: string | StorageProvider | { type: 'postgres'; url: string }; /** * Your address on the prekey server (e.g. "alice@example.com" or "device:abc123"). * If omitted (undefined), a random UUID is generated and persisted. */ address?: string | undefined; /** * Auto-replenish configuration. When the one-time prekey stock drops * below `min`, the SDK will generate enough to reach `target` and * upload them to the prekey server. * * Default: { min: 5, target: 20, intervalMs: 60_000 } * Pass `false` to disable. */ autoReplenish?: { min: number; target: number; intervalMs: number } | false; /** * Auto identity rotation. Default OFF. * Pass an interval string like '7d' or '30d' to enable periodic rotation. */ autoRotate?: false | '1d' | '7d' | '30d' | '90d'; /** * Optional observer configuration. If provided, starts a Shade Observer * endpoint for the dashboard. */ observer?: { /** Bearer token for the observer. Must be at least 16 chars. */ token: string; /** Port to listen on. If not set, observer is created but not served. */ port?: number; /** Path prefix to mount under (default: "/shade-observer") */ basePath?: string; }; /** * Optional OpenTelemetry observability hook. Build with * `withTracer(otelTracer)` from `@shade/observability`. Default: off. * * The hook propagates to the session manager (encrypt/decrypt spans), * transfer engine (upload/download), and `@shade/files` (per-op). * `withTracer()` is itself a no-op unless `SHADE_OTEL_ENABLED` is set, * so leaving this configured in code stays free in production until * the env-var flips it on. */ observability?: ObservabilityHook; /** * Optional Key-Transparency verifier (V3.12). When set, every * `fetchBundle` validates the server's inclusion proof against the * pinned `logPublicKey` and feeds the STH into a `LightWitness` for * split-view detection. * * Modes: * - `'observe'` — verify proofs when present, do not fail when missing. * - `'observe-strict'` — require a proof on every successful and 404 response. * * Default: KT verification disabled. Set this to enable. */ keyTransparency?: ShadeKTConfig; } /** Public configuration shape for `Shade.keyTransparency`. */ export interface ShadeKTConfig { mode: 'observe' | 'observe-strict'; /** Operator's pinned signing public key (32-byte Ed25519) — base64 or raw bytes. */ logPublicKey: Uint8Array | string; /** Reject STHs older than this many ms. Default 24h. */ maxStaleMs?: number; /** Cap on observed-STH cache. Default 1024. */ witnessMaxStored?: number; } export interface ResolvedConfig { prekeyServer: string; storage: string | StorageProvider | { type: 'postgres'; url: string }; address?: string | undefined; autoReplenish: { min: number; target: number; intervalMs: number } | false; autoRotate: false | '1d' | '7d' | '30d' | '90d'; observer?: { token: string; port?: number | undefined; basePath: string; }; observability?: ObservabilityHook; keyTransparency?: ResolvedKTConfig; } export interface ResolvedKTConfig { mode: 'observe' | 'observe-strict'; logPublicKey: Uint8Array; maxStaleMs: number; witnessMaxStored: number; } /** Parse and validate a ShadeConfig, resolving defaults and env var overrides */ export function resolveConfig(input: ShadeConfig): ResolvedConfig { if (!input.prekeyServer) { throw new ConfigurationError('prekeyServer is required'); } const autoReplenish = input.autoReplenish === false ? false : { min: input.autoReplenish?.min ?? 5, target: input.autoReplenish?.target ?? 20, intervalMs: input.autoReplenish?.intervalMs ?? 60_000, }; const resolved: ResolvedConfig = { prekeyServer: input.prekeyServer.replace(/\/$/, ''), storage: input.storage ?? 'memory', address: input.address, autoReplenish, autoRotate: input.autoRotate ?? false, }; if (input.observer) { if (!input.observer.token || input.observer.token.length < 16) { throw new ConfigurationError('observer.token must be at least 16 characters'); } resolved.observer = { token: input.observer.token, port: input.observer.port, basePath: input.observer.basePath ?? '/shade-observer', }; } if (input.observability !== undefined) { resolved.observability = input.observability; } if (input.keyTransparency !== undefined) { const kt = input.keyTransparency; if (kt.mode !== 'observe' && kt.mode !== 'observe-strict') { throw new ConfigurationError( `keyTransparency.mode must be 'observe' or 'observe-strict'`, ); } let logKey: Uint8Array; if (typeof kt.logPublicKey === 'string') { try { logKey = new Uint8Array(Buffer.from(kt.logPublicKey, 'base64')); } catch { throw new ConfigurationError( 'keyTransparency.logPublicKey must be base64 or Uint8Array', ); } } else { logKey = kt.logPublicKey; } if (logKey.length !== 32) { throw new ConfigurationError( `keyTransparency.logPublicKey must be 32 bytes (got ${logKey.length})`, ); } resolved.keyTransparency = { mode: kt.mode, logPublicKey: logKey, maxStaleMs: kt.maxStaleMs ?? 24 * 60 * 60 * 1000, witnessMaxStored: kt.witnessMaxStored ?? 1024, }; } return resolved; } /** Parse a rotation interval string ('7d', '30d', etc.) into milliseconds */ export function parseRotationInterval( interval: '1d' | '7d' | '30d' | '90d', ): number { const map: Record = { '1d': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000, }; return map[interval]!; }