# Capability Spec Delta: currency-providers (slim-currency-providers) **Package**: `@suaveplan/currency-providers` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. > No exceptions. This delta MODIFIES the canonical spec landed by the > 2026-05-17 `add-currency-providers` change. ## REMOVED Requirements ### Requirement: EcbProvider — ECB daily XML feed **Reason**: Moves to sibling package `@suaveplan/currency-provider-ecb` per the foundation-with-sibling-plugins refactor. The contract is unchanged; only the package boundary moves. See `./design.md` for the shape. --- ### Requirement: FixerProvider — Fixer.io with API key **Reason**: Moves to sibling package `@suaveplan/currency-provider-fixer`. --- ### Requirement: OpenExchangeRatesProvider — OXR with API key **Reason**: Moves to sibling package `@suaveplan/currency-provider-open-exchange-rates`. --- ### Requirement: BinanceProvider — Binance spot ticker **Reason**: Moves to sibling package `@suaveplan/currency-provider-binance`. --- ### Requirement: CoinGeckoProvider — simple/price endpoint + getCoinGeckoId **Reason**: Moves to sibling package `@suaveplan/currency-provider-coingecko`. --- ### Requirement: CoinMarketCapProvider — quotes/latest with API key header **Reason**: Moves to sibling package `@suaveplan/currency-provider-coinmarketcap`. --- ### Requirement: CryptoCompareProvider — pricemulti **Reason**: Moves to sibling package `@suaveplan/currency-provider-cryptocompare`. --- ### Requirement: KrakenProvider — Ticker with pair→symbol reverse mapping **Reason**: Moves to sibling package `@suaveplan/currency-provider-kraken`. --- ### Requirement: VirtualCurrencyProvider — operator-defined currencies **Reason**: Moves to sibling package `@suaveplan/currency-provider-virtual` (retains its `@suaveplan/money` `registerCurrency` integration there). ## MODIFIED Requirements ### Requirement: Stable CURRENCY_PROVIDER_CODES error registry and CurrencyProviderError class The package SHALL declare an error-code registry via `defineCodes("CURRENCY_PROVIDER", [...])` containing exactly the codes `HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `UNSUPPORTED_BASE`, `MISSING_CONFIG`, `UNKNOWN_CURRENCY`, `PROVIDER_NOT_REGISTERED`, and `PROVIDER_ALREADY_REGISTERED`. Every public error path in this package and every sibling `@suaveplan/currency-provider-` package SHALL throw a `CurrencyProviderError extends SuaveplanError` carrying one of those codes. `CurrencyProviderError` SHALL expose optional `providerName`, `status`, and `upstreamMessage` discriminants for diagnostics. The package SHALL NOT throw raw `Error` instances from any public method. #### Scenario: registry contains every required code - **WHEN** the `CURRENCY_PROVIDER_CODES` object is enumerated - **THEN** it contains exactly the eight namespaced codes (`CURRENCY_PROVIDER.HTTP_ERROR` through `CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED`) and no others #### Scenario: registry codes are reachable from this package - **WHEN** the test suite calls `registry.get("nope")` on an empty registry and calls `registry.register(p)` twice for the same provider name - **THEN** each path raises a `CurrencyProviderError` whose `code` matches the corresponding `CURRENCY_PROVIDER.PROVIDER_*` constant #### Scenario: HTTP_ERROR is reachable from the shared httpRequest helper - **WHEN** `httpRequest({ url, fetch, providerName })` is called with an injected fetch that returns a non-2xx Response - **THEN** the call rejects with `code === "CURRENCY_PROVIDER.HTTP_ERROR"` and `status` populated from the response #### Scenario: SuaveplanError contract preserved - **WHEN** a `CurrencyProviderError` is thrown - **THEN** it is `instanceof SuaveplanError`, its `code` is a `CurrencyProviderErrorCode`, and its optional `providerName` / `status` / `upstreamMessage` discriminants are accessible without type narrowing **Verified by:** `src/error-codes/error-codes.test.ts::"declares every required code with namespaced value"` AND `src/error-codes/error-codes.test.ts::"preserves SuaveplanError contract"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"` AND `src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response"` --- ### Requirement: Configuration types are named interfaces The package SHALL export `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`, and `ProviderConfigBase` as named TypeScript interfaces (no inline object literals in constructor or factory signatures). Sibling `@suaveplan/currency-provider-` packages SHALL type their API-keyed configs as `ApiKeyConfig`, their no-key configs as `NoKeyConfig`, and their HTTP-call options as `HttpFetchOptions`. Every provider config SHALL extend with an optional `fetch?: FetchLike` (carried on `ProviderConfigBase`). #### Scenario: configs are exported from the slim core - **WHEN** a consumer imports `ApiKeyConfig`, `NoKeyConfig`, `HttpFetchOptions`, and `ProviderConfigBase` from `@suaveplan/currency-providers` - **THEN** all four types are accepted by TypeScript and `ApiKeyConfig` enforces a required `apiKey: string` **Verified by:** `src/registry/registry.test.ts::"accepts a defineCurrencyProvider-produced provider via ApiKeyConfig contract"` AND `src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"` --- ### Requirement: Explicit named barrel exports The package SHALL re-export every public symbol from `src/index.ts` using explicit named re-exports (`export { Name } from "..."`) — no `export *` anywhere except namespace re-exports (`export * as from`). Every feature module (`/index.ts`) SHALL likewise use explicit named re-exports. #### Scenario: src/index.ts has no `export *` - **WHEN** `grep -nE "^\s*export\s+(type\s+)?\*\s+from" src/index.ts` is run - **THEN** the command produces zero matches **Verified by:** `tests/dep-graph.test.ts::"src/index.ts has no export * statements"` AND `tests/dep-graph.test.ts::"feature barrels have no export * statements"` --- ### Requirement: Subpath exports per public module `package.json` SHALL declare a subpath export for every public module so consumers can tree-shake. The slim core SHALL declare exactly five subpaths beyond `./package.json` and the root `.`: `./error-codes/error-codes`, `./types/configs`, `./registry/registry`, `./factory/factory`, `./http/http`. No `./providers/*` subpaths SHALL be declared — provider implementations live in sibling packages. #### Scenario: every feature module has a subpath export - **WHEN** `package.json` `exports` map is enumerated - **THEN** it contains exactly six entries (root `.` + the five listed subpaths), each pointing to the matching compiled `.js` and `.d.ts`, and it contains zero entries starting with `./providers/` **Verified by:** `tests/dep-graph.test.ts::"package.json declares the five slim-core subpath exports and no provider subpaths"` ## ADDED Requirements ### Requirement: CurrencyProviderRegistry — name-keyed lookup of CurrencyProvider instances The package SHALL export a `CurrencyProviderRegistry` class that stores `CurrencyProvider` instances keyed by `provider.name`. The class SHALL expose `register(provider: CurrencyProvider): void`, `get(name: string): CurrencyProvider`, `has(name: string): boolean`, `list(): readonly string[]`, and `unregister(name: string): boolean`. `get` SHALL throw `CurrencyProviderError(PROVIDER_NOT_REGISTERED)` when no provider has been registered under `name`. `register` SHALL throw `CurrencyProviderError(PROVIDER_ALREADY_REGISTERED)` when a provider with the same `name` is already registered. `list()` SHALL return a sorted snapshot (lexicographic by name) so equal sets of providers produce identical listings regardless of insertion order. `unregister` SHALL return `true` when something was removed and `false` otherwise, and SHALL NOT throw on unknown names. #### Scenario: registers and retrieves a provider - **WHEN** a provider with `name === "fake-fx"` is registered and then `registry.get("fake-fx")` is called - **THEN** the registry returns the same provider instance by identity (`===`) and `registry.has("fake-fx")` returns `true` #### Scenario: raises PROVIDER_NOT_REGISTERED when get is called on an unknown name - **WHEN** `registry.get("never-registered")` is called on a fresh registry - **THEN** it throws a `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and whose `providerName === "never-registered"` #### Scenario: raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name - **WHEN** two distinct provider instances sharing the same `name === "dup"` are passed to `register` in sequence - **THEN** the second call throws a `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and whose `providerName === "dup"` #### Scenario: list returns a sorted snapshot - **WHEN** providers are registered in the order `"zeta"`, `"alpha"`, `"mu"` - **THEN** `registry.list()` returns `["alpha", "mu", "zeta"]` (lexicographic) #### Scenario: unregister removes and reports - **WHEN** `registry.unregister("alpha")` is called after `"alpha"` was registered, followed by a second `registry.unregister("alpha")` - **THEN** the first call returns `true`, the second returns `false`, `registry.has("alpha")` returns `false`, and a subsequent `registry.get("alpha")` throws `PROVIDER_NOT_REGISTERED` **Verified by:** `src/registry/registry.test.ts::"registers and retrieves a provider"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"` AND `src/registry/registry.test.ts::"list returns a sorted snapshot"` AND `src/registry/registry.test.ts::"unregister removes and reports correctly"` --- ### Requirement: defineCurrencyProvider — factory for object-literal providers The package SHALL export `defineCurrencyProvider(input)` — a factory that returns an `Object.freeze`-d implementation of the `CurrencyProvider` contract from `@suaveplan/currency`. The factory input SHALL be a named interface (`DefineCurrencyProviderInput`) requiring `name: string`, `supportsCrypto: boolean`, `supportsFiat: boolean`, and `fetchRates: (options?: RateFetchOptions) => Promise`, plus an optional `supportsVirtual?: boolean`. The returned provider SHALL forward every `fetchRates` call to the supplied function unchanged and SHALL preserve every optional flag. #### Scenario: factory returns a frozen CurrencyProvider - **WHEN** `defineCurrencyProvider({ name: "fake", supportsCrypto: false, supportsFiat: true, fetchRates: async () => ({ base: "USD", rates: {}, timestamp: 0 }) })` is called - **THEN** the returned object is `Object.frozen`, satisfies the `CurrencyProvider` type, and exposes `name === "fake"`, `supportsCrypto === false`, `supportsFiat === true` #### Scenario: factory forwards fetchRates calls - **WHEN** a provider built by the factory has its `fetchRates(options)` called with `options === { base: "EUR" }` - **THEN** the underlying user-supplied function receives the same `options` and the promise it returns is the value returned by the provider #### Scenario: factory preserves supportsVirtual - **WHEN** the factory is called with `supportsVirtual: true` - **THEN** the returned provider exposes `supportsVirtual === true`; when the flag is omitted the property is absent **Verified by:** `src/factory/factory.test.ts::"returns a frozen CurrencyProvider"` AND `src/factory/factory.test.ts::"forwards fetchRates calls to the supplied function"` AND `src/factory/factory.test.ts::"preserves supportsVirtual when provided"` AND `src/factory/factory.test.ts::"NoKeyConfig and ApiKeyConfig are assignable to the factory input"` --- ### Requirement: httpRequest + composeAbortSignal — shared HTTP utilities for sibling providers The package SHALL export `httpRequest(input)` — an async helper that performs a request through an injected `FetchLike`, applies an optional `timeoutMs` via `AbortSignal.timeout`, composes the caller's optional `signal` with the timeout signal via `composeAbortSignal`, attaches the combined signal and any `init` to the request, throws `CurrencyProviderError(HTTP_ERROR)` (with `providerName` and `status` populated) on a non-2xx response, and returns the raw `Response` otherwise. The package SHALL also export `composeAbortSignal(caller, timeout)` — a pure helper that returns `undefined` when both inputs are `undefined`, returns the single input when only one is provided, and returns a combined signal that aborts when either input aborts when both are provided. #### Scenario: returns the response on a 2xx - **WHEN** `httpRequest({ url: "https://x/y", fetch: ok, providerName: "fake" })` is called against a recording fetch that returns `new Response("ok", { status: 200 })` - **THEN** the helper resolves to that Response, and the recorded URL and init exactly match the inputs #### Scenario: raises HTTP_ERROR on non-2xx response - **WHEN** the injected fetch returns a `Response` with `status: 503` - **THEN** the helper rejects with `CurrencyProviderError` whose `code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, and `providerName === "fake"` #### Scenario: forwards init.headers to the injected fetch - **WHEN** `httpRequest({ ..., init: { headers: { "x-test": "1" } } })` is called against a recording fetch - **THEN** the recorded `init.headers` contains `"x-test" === "1"` #### Scenario: aborts when the caller signal aborts - **WHEN** `httpRequest({ ..., signal })` is called and the caller aborts `signal` before the fetch resolves - **THEN** the helper rejects with the abort reason (a `DOMException` or the signal's `reason`) #### Scenario: aborts when the timeout signal fires - **WHEN** `httpRequest({ ..., timeoutMs: 1 })` is called against a fetch that never resolves within the timeout - **THEN** the helper rejects with a timeout abort #### Scenario: composeAbortSignal returns undefined when both inputs are undefined - **WHEN** `composeAbortSignal(undefined, undefined)` is called - **THEN** the result is `undefined` #### Scenario: composeAbortSignal returns the single input when only one is provided - **WHEN** `composeAbortSignal(callerOnly, undefined)` or `composeAbortSignal(undefined, timeoutOnly)` is called - **THEN** the result is identically `callerOnly` or `timeoutOnly` respectively #### Scenario: composeAbortSignal aborts when either input aborts - **WHEN** both a caller `AbortController` and a timeout `AbortController` are passed and either one fires - **THEN** the composed signal's `aborted` flag becomes `true` **Verified by:** `src/http/http.test.ts::"returns the response on a 2xx"` AND `src/http/http.test.ts::"raises HTTP_ERROR on non-2xx response"` AND `src/http/http.test.ts::"forwards init.headers to the injected fetch"` AND `src/http/http.test.ts::"aborts when the caller signal aborts"` AND `src/http/http.test.ts::"aborts when the timeout signal fires"` AND `src/http/http.test.ts::"composeAbortSignal returns undefined when both inputs are undefined"` AND `src/http/http.test.ts::"composeAbortSignal returns the single input when only one is provided"` AND `src/http/http.test.ts::"composeAbortSignal aborts when either input aborts"` --- ### Requirement: PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED codes added to the registry The `CURRENCY_PROVIDER_CODES` registry SHALL add `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` so the new `CurrencyProviderRegistry` can dispatch on typed codes (no plain `Error`). Both codes SHALL be reachable from `src/` call sites in this package's test suite. #### Scenario: both new codes are present and namespaced - **WHEN** `CURRENCY_PROVIDER_CODES.PROVIDER_NOT_REGISTERED` and `CURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED` are read - **THEN** the values are `"CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"` and `"CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"` respectively **Verified by:** `src/error-codes/error-codes.test.ts::"declares every required code with namespaced value"` AND `src/registry/registry.test.ts::"raises PROVIDER_NOT_REGISTERED when get is called on an unknown name"` AND `src/registry/registry.test.ts::"raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name"`