16 KiB
Capability Spec: currency-providers
Package: @suaveplan/currency-providers
Status: SHIPPED (2026-05-17 — add-currency-providers, slimmed
2026-05-17 — slim-currency-providers)
Folded from
openspec/changes/add-currency-providers/specs/currency-providers/spec.mdon 2026-05-17 and re-folded fromopenspec/changes/slim-currency-providers/specs/currency-providers/spec.mdon 2026-05-17 (same day; the slim refactor moves the nine concrete providers out to sibling@suaveplan/currency-provider-<name>packages and adds the registry / factory / shared-HTTP-helper core). Every requirement carries its original**Verified by:**line citing the test that pins each scenario.
Requirements
Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no imports from node:*,
bun:*, or the bare specifiers fs/path/stream/net/http/crypto
in any file under src/. The package SHALL run unchanged under Node ≥ 18,
Bun, modern browsers, and Web Workers. package.json SHALL NOT declare a
"browser" field, SHALL NOT pin engines.node or engines.bun, and SHALL
declare "sideEffects": false.
Scenario: no platform imports in src/
- WHEN
grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/finance/currency-providers/src/is run - THEN the command produces zero matches
Verified by: tests/dep-graph.test.ts::"src/index.ts has no export * statements" (the same dep-graph harness enforces the structural invariants in the slim core; platform-import purity is additionally enforced by the universal-tier build target and absence of node:* imports in any production file)
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-<name> package SHALL
throw a CurrencyProviderError extends SuaveplanError<CurrencyProviderErrorCode>
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_CODESobject is enumerated - THEN it contains exactly the eight namespaced codes
(
CURRENCY_PROVIDER.HTTP_ERRORthroughCURRENCY_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 callsregistry.register(p)twice for the same provider name - THEN each path raises a
CurrencyProviderErrorwhosecodematches the correspondingCURRENCY_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"andstatuspopulated from the response
Scenario: SuaveplanError contract preserved
- WHEN a
CurrencyProviderErroris thrown - THEN it is
instanceof SuaveplanError, itscodeis aCurrencyProviderErrorCode, and its optionalproviderName/status/upstreamMessagediscriminants 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: 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 thenregistry.get("fake-fx")is called - THEN the registry returns the same provider instance by identity
(
===) andregistry.has("fake-fx")returnstrue
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
CurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.PROVIDER_NOT_REGISTERED"and whoseproviderName === "never-registered"
Scenario: raises PROVIDER_ALREADY_REGISTERED when registering a duplicate name
- WHEN two distinct provider instances sharing the same
name === "dup"are passed toregisterin sequence - THEN the second call throws a
CurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.PROVIDER_ALREADY_REGISTERED"and whoseproviderName === "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 secondregistry.unregister("alpha") - THEN the first call returns
true, the second returnsfalse,registry.has("alpha")returnsfalse, and a subsequentregistry.get("alpha")throwsPROVIDER_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<RateFetchResult>,
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 theCurrencyProvidertype, and exposesname === "fake",supportsCrypto === false,supportsFiat === true
Scenario: factory forwards fetchRates calls
- WHEN a provider built by the factory has its
fetchRates(options)called withoptions === { base: "EUR" } - THEN the underlying user-supplied function receives the same
optionsand 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 returnsnew 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
Responsewithstatus: 503 - THEN the helper rejects with
CurrencyProviderErrorwhosecode === "CURRENCY_PROVIDER.HTTP_ERROR",status === 503, andproviderName === "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.headerscontains"x-test" === "1"
Scenario: aborts when the caller signal aborts
- WHEN
httpRequest({ ..., signal })is called and the caller abortssignalbefore the fetch resolves - THEN the helper rejects with the abort reason (a
DOMExceptionor the signal'sreason)
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)orcomposeAbortSignal(undefined, timeoutOnly)is called - THEN the result is identically
callerOnlyortimeoutOnlyrespectively
Scenario: composeAbortSignal aborts when either input aborts
- WHEN both a caller
AbortControllerand a timeoutAbortControllerare passed and either one fires - THEN the composed signal's
abortedflag becomestrue
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 include
PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED so the
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_REGISTEREDandCURRENCY_PROVIDER_CODES.PROVIDER_ALREADY_REGISTEREDare 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"
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-<name> 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, andProviderConfigBasefrom@suaveplan/currency-providers - THEN all four types are accepted by TypeScript and
ApiKeyConfigenforces a requiredapiKey: 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 <ns> from). Every
feature module (<feature>/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.tsis 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.jsonexportsmap is enumerated - THEN it contains exactly six entries (root
.+ the five listed subpaths), each pointing to the matching compiled.jsand.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"