/** * Credential seam (`ctx.credentials`). Settings and composition files carry * *references* to secrets — environment-variable names — while providers own * the actual values and their storage. Consumers resolve a reference once per * operation, so a changed credential reaches the next operation without any * plugin restart, and configuration surfaces describe a reference without * ever seeing its value. * @module @deepseek-ai/dsh-credentials */ import { Context, Service } from 'cordis' import type { Branded } from '@deepseek-ai/dsh-brand' /** Nominal reference to one credential: a POSIX-style environment-variable name. */ export type CredentialRef = Branded<'CredentialRef'> const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ /** * Brand a raw string as a {@link CredentialRef}. * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`. * @returns the branded reference. */ export function credentialRef(value: string): CredentialRef { if (!REF_PATTERN.test(value)) { throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) } return value as CredentialRef } /** One resolved credential value and the source layer that supplied it. */ export interface ResolvedCredential { /** The non-empty secret value. */ value: string /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */ source: string } /** Source and writability facts for one reference, safe for configuration UIs — never the value. */ export interface CredentialInfo { /** Whether {@link Credentials.resolve} would currently return a value. */ configured: boolean /** Source layer currently supplying the value; absent while unconfigured. */ source?: string /** Whether {@link Credentials.set} would currently succeed for this reference. */ writable: boolean } declare module 'cordis' { interface Context { credentials: Credentials } interface Events { /** * Committed change to a provider-managed credential source: a `set`, an * `unset`, or an external edit observed in storage. Ambient * process-environment changes are not observable and never emit. Listener * failures are contained and logged — a sync throw and an async rejection * alike — without changing the committed operation's outcome, except * `INVARIANT`-coded failures, which rethrow after every listener ran; * that rethrow reaches the emitter only from synchronous listeners, so * invariant checks on this event must not be async functions. * @param ref - the reference whose stored value changed. * @mode emit */ 'credentials/updated'(ref: CredentialRef): void } } /** * Abstract credential service. Providers implement the four operations over * their source layers; one seam-wide rule binds them all: an empty stored * value is absent everywhere — `resolve` skips it, `describe` reports it * unconfigured — so a blank never masquerades as a configured secret. */ export abstract class Credentials extends Service { constructor(ctx: Context) { super(ctx, 'credentials') } /** * Resolve one reference to its current value. Resolution is per call: * consumers re-resolve at each operation and must not cache across * operations — that per-operation read is what makes a changed credential * reach the next operation without a restart. * @param ref - the reference to resolve. * @returns the value and its source, or `undefined` while unconfigured. */ abstract resolve(ref: CredentialRef): Promise /** * Describe one reference for configuration surfaces without exposing the * value. * @param ref - the reference to describe. * @returns configured state, supplying source, and writability. */ abstract describe(ref: CredentialRef): Promise /** * Durably store one value in the provider-managed writable source. Rejects * while a read-only source shadows the reference — the write would appear * to succeed while resolution keeps returning the shadowing value — and * rejects an empty value (use {@link unset}). * @param ref - the reference to store. * @param value - the non-empty secret value. */ abstract set(ref: CredentialRef, value: string): Promise /** * Remove one reference from the provider-managed writable source; removing * an absent reference is a no-op. Rejects while a read-only source shadows * the reference, like {@link set}. * @param ref - the reference to remove. */ abstract unset(ref: CredentialRef): Promise /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit fan-out: the contained-dispatch shape is the reviewed listener-lifecycle contract, and extracting it would couple the two seams' event semantics. */ /** * Fan `credentials/updated` out with contained listener failures: every * listener runs, and a sync throw or async rejection is logged without * changing the committed operation's outcome — except `INVARIANT`-coded * failures, which rethrow after every listener ran (the rethrow reaches the * caller only from synchronous listeners, so invariant checks on this event * must not be async functions). Providers call this only after the write or * reload actually committed, so a broken observer can never make a durable * change look failed. * @param ref - the reference whose stored value changed. */ protected notifyUpdated(ref: CredentialRef): void { let invariantFailure: unknown const args = ['credentials/updated', ref] for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { try { const returned = listener(ref) if (returned != null && typeof (returned as PromiseLike).then === 'function') { void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { this.warnListenerFailure(ref, error) }) } } catch (error) { if ((error as { code?: unknown } | null)?.code === 'INVARIANT') { invariantFailure ??= error continue } this.warnListenerFailure(ref, error) } } if (invariantFailure !== undefined) throw invariantFailure as Error } /* jscpd:ignore-end */ /** Contained-listener diagnostic shared by the sync and async failure paths. */ private warnListenerFailure(ref: CredentialRef, error: unknown): void { this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref) this.ctx.logger.warn(error) } } export default Credentials