Initial Commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Add @suaveplan/currency-provider-open-exchange-rates
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-open-exchange-rates`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Spec delta**: ./specs/currency-provider-open-exchange-rates/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-17 slim of `@suaveplan/currency-providers` removed the nine bundled
|
||||
provider implementations and committed to landing each as an independent
|
||||
sibling package on the foundation-with-sibling-plugins shape established by
|
||||
`@suaveplan/network-plugin-*` and `@suaveplan/drive-plugin-*`. This change
|
||||
lands the Open Exchange Rates (OXR) provider so consumers that only need OXR
|
||||
install one small package instead of the bundled umbrella, and OXR can iterate
|
||||
on its own version cadence when the upstream API changes (free-plan base-only,
|
||||
historical endpoints, currency-list updates).
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `OpenExchangeRatesProvider` — class implementing `CurrencyProvider`
|
||||
from `@suaveplan/currency`; consumes `httpRequest()` from
|
||||
`@suaveplan/currency-providers`; raises `CurrencyProviderError` with codes
|
||||
`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `MISSING_CONFIG`,
|
||||
`UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY`.
|
||||
- ADDED: subpath export `./provider/provider` for tree-shakeable import.
|
||||
- ADDED: package root barrel re-exporting `OpenExchangeRatesProvider` and the
|
||||
type alias `OpenExchangeRatesProviderConfig` (= `ApiKeyConfig` from
|
||||
`@suaveplan/currency-providers`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Historical-rate endpoint (`/historical/<date>.json`) — deferred to a
|
||||
follow-up change. The upstream slim refactor's `## Out of scope` block
|
||||
explicitly defers historical rates for every provider in the wave; this
|
||||
package keeps parity with that boundary.
|
||||
- Retry / backoff / rate-limit-header surfacing / OTel — deferred to the same
|
||||
cross-cutting follow-up that the slim core deferred them to.
|
||||
- The five other OXR endpoints (`/currencies.json`, `/usage.json`,
|
||||
`/convert/{value}/{from}/{to}`, `/ohlc.json`, `/time-series.json`) — the
|
||||
`CurrencyProvider` contract only exposes bulk-fetch via `/latest.json`; the
|
||||
rest are out of contract.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/currency-providers.md` and
|
||||
the upstream OXR implementation at
|
||||
`../suaveplan/packages/finance/currency-providers/src/fiat/open-exchange-rates.ts`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| OXR-001 implements `CurrencyProvider` with `supportsFiat=true`, `supportsCrypto=false`, `name="open-exchange-rates"` | `open-exchange-rates.ts` L15-L19 | verbatim, plus tier purity (no Node-only APIs) |
|
||||
| OXR-002 fetches `/latest.json?app_id=<key>&base=<base>` via `httpRequest()` | `open-exchange-rates.ts` L28-L37 | reworded — upstream called `fetch()` directly; genesis routes through slim-core `httpRequest()` per slim spec |
|
||||
| OXR-003 maps response `{ base, rates, timestamp }` to `RateFetchResult` with `timestamp * 1000` to convert OXR's Unix seconds to ms | `open-exchange-rates.ts` L39-L43, L56-L60 | verbatim |
|
||||
| OXR-004 honours `RateFetchOptions.targets` by filtering `rates` map | `open-exchange-rates.ts` L46-L54 | verbatim |
|
||||
| OXR-005 free-tier limits base to USD; explicit non-USD base raises `UNSUPPORTED_BASE` | enterprise audit L12, OXR.md L11-L13, L41-L43 | extended — upstream silently let upstream API reject; genesis pre-rejects with a stable error code (the `UNSUPPORTED_BASE` code is already registered in slim core) |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| OXR-006 raises `MISSING_CONFIG` on empty `apiKey` | `MISSING_CONFIG` code exists in slim-core registry per archived slim spec; surfacing it from constructor is the genesis baseline for all api-key providers |
|
||||
| OXR-007 raises `PARSE_ERROR` when upstream payload is not the expected shape | Defensive validation — upstream library threw a generic JSON-parse error; genesis maps to typed `PARSE_ERROR` |
|
||||
| OXR-008 raises `UPSTREAM_ERROR` when upstream returns 2xx with `error: true` body | OXR documented `error/status/message` payload shape; upstream library missed it. Genesis surfaces typed `UPSTREAM_ERROR` with `upstreamMessage` populated |
|
||||
| OXR-009 raises `UNKNOWN_CURRENCY` when `options.targets` requests a code not in upstream response | `UNKNOWN_CURRENCY` code exists in slim-core registry; surfacing it instead of silently dropping the target gives callers a typed handle |
|
||||
| OXR-010 honours `RateFetchOptions.signal` for cancellation | Universal-tier abort propagation — upstream did not honour signal at all; genesis composes via `httpRequest()` which forwards to the injected fetch |
|
||||
| OXR-011 universal-tier purity — no Node APIs, no DOM APIs | Genesis-wide tier policy |
|
||||
| OXR-012 100% test coverage, sibling `.md` on every implementation file | Genesis-wide quality policy |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement carries a `**Verified by:**` line in the spec delta pointing
|
||||
at concrete `it(...)` titles. The test surface is:
|
||||
|
||||
- `src/provider/provider.test.ts` — covers OXR-001 through OXR-011 with
|
||||
injected `FetchLike` recording calls and returning canned responses.
|
||||
- `tests/dep-graph.test.ts` — pins dependency invariants (only consumes
|
||||
`@suaveplan/currency`, `@suaveplan/currency-providers`, `@suaveplan/error`,
|
||||
`@suaveplan/types`).
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No current consumers reference this package; the slim
|
||||
core's `## Affected dependents` block listed this package as one of nine
|
||||
sibling consumers that would land in the follow-up wave.
|
||||
@@ -0,0 +1,189 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: currency-provider-open-exchange-rates (add-currency-provider-open-exchange-rates)
|
||||
|
||||
**Package**: `@suaveplan/currency-provider-open-exchange-rates`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: OXR-001 — Implements CurrencyProvider with stable identity
|
||||
|
||||
The package SHALL export `OpenExchangeRatesProvider`, a class that satisfies
|
||||
the `CurrencyProvider` contract from `@suaveplan/currency` with the static
|
||||
identity fields `name === "open-exchange-rates"`, `supportsFiat === true`,
|
||||
`supportsCrypto === false`, and `supportsVirtual` left unset (no virtual
|
||||
support).
|
||||
|
||||
#### Scenario: identity fields are set after construction
|
||||
|
||||
- **WHEN** a caller constructs `new OpenExchangeRatesProvider({ apiKey: "k" })`
|
||||
- **THEN** `.name === "open-exchange-rates"`, `.supportsFiat === true`, `.supportsCrypto === false`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"exposes stable identity fields"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-002 — Routes /latest.json through slim-core httpRequest
|
||||
|
||||
The package SHALL fetch from `${baseUrl}/latest.json?app_id=<apiKey>&base=<base>`
|
||||
via the `httpRequest()` helper exported by `@suaveplan/currency-providers`,
|
||||
passing through any caller `signal` from `RateFetchOptions`.
|
||||
|
||||
#### Scenario: builds the canonical URL and forwards signal
|
||||
|
||||
- **WHEN** `fetchRates({ base: "USD", signal })` is called with an injected `FetchLike`
|
||||
- **THEN** the injected fetch is invoked exactly once with URL `https://openexchangerates.org/api/latest.json?app_id=<key>&base=USD` and an `init.signal` derived from the caller signal
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"calls /latest.json with app_id and base, forwarding caller signal"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-003 — Maps upstream payload to RateFetchResult with ms timestamp
|
||||
|
||||
The package SHALL parse the OXR JSON payload `{ base, rates, timestamp }` and
|
||||
return a `RateFetchResult` where `result.base === payload.base`,
|
||||
`result.rates === payload.rates` (or its filtered subset per OXR-004), and
|
||||
`result.timestamp === payload.timestamp * 1000` (OXR returns Unix seconds;
|
||||
genesis exposes ms).
|
||||
|
||||
#### Scenario: timestamp is converted to milliseconds
|
||||
|
||||
- **WHEN** OXR returns `{ base: "USD", rates: { EUR: 0.91 }, timestamp: 1_700_000_000 }`
|
||||
- **THEN** the returned `RateFetchResult` has `timestamp === 1_700_000_000_000` and `rates.EUR === 0.91`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"converts OXR Unix-seconds timestamp to milliseconds"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-004 — Filters rates when options.targets is supplied
|
||||
|
||||
When `RateFetchOptions.targets` is supplied, the package SHALL return only
|
||||
the rate entries whose currency code is in `targets`; codes absent from the
|
||||
upstream payload raise `UNKNOWN_CURRENCY` (see OXR-009).
|
||||
|
||||
#### Scenario: only requested targets are returned
|
||||
|
||||
- **WHEN** `fetchRates({ targets: ["EUR", "GBP"] })` is called and upstream returns `{ EUR: 0.91, GBP: 0.79, JPY: 150 }`
|
||||
- **THEN** the returned `rates` map has exactly the keys `EUR` and `GBP`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"filters rates down to options.targets"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-005 — Rejects non-USD base on free tier with UNSUPPORTED_BASE
|
||||
|
||||
The package SHALL accept the free-tier constraint (USD base only) by default.
|
||||
When `RateFetchOptions.base` is supplied AND it is not `"USD"` AND
|
||||
`config.allowNonUsdBase` is not set to `true`, the package SHALL raise
|
||||
`CurrencyProviderError(UNSUPPORTED_BASE)` before issuing the request. Paid
|
||||
plans enable any base by passing `allowNonUsdBase: true` to the constructor.
|
||||
|
||||
#### Scenario: free-tier rejects non-USD base
|
||||
|
||||
- **WHEN** a provider constructed with `{ apiKey: "k" }` (allowNonUsdBase omitted) is called as `fetchRates({ base: "EUR" })`
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNSUPPORTED_BASE"`, `providerName === "open-exchange-rates"`, and no network request is issued
|
||||
|
||||
#### Scenario: paid-tier accepts non-USD base
|
||||
|
||||
- **WHEN** a provider constructed with `{ apiKey: "k", allowNonUsdBase: true }` is called as `fetchRates({ base: "EUR" })`
|
||||
- **THEN** the injected fetch is invoked with `base=EUR` in the query string
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"raises UNSUPPORTED_BASE when free-tier caller requests non-USD base"` AND `src/provider/provider.test.ts::"honours allowNonUsdBase=true and forwards base to /latest.json"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-006 — Rejects empty apiKey with MISSING_CONFIG
|
||||
|
||||
The package SHALL raise `CurrencyProviderError(MISSING_CONFIG)` at construction
|
||||
time when `config.apiKey` is the empty string. A non-empty string is the only
|
||||
valid input.
|
||||
|
||||
#### Scenario: empty apiKey is rejected synchronously
|
||||
|
||||
- **WHEN** a caller invokes `new OpenExchangeRatesProvider({ apiKey: "" })`
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.MISSING_CONFIG"` and `providerName === "open-exchange-rates"`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"raises MISSING_CONFIG when apiKey is empty"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-007 — Raises PARSE_ERROR on malformed upstream payload
|
||||
|
||||
The package SHALL validate the upstream payload shape; if `base`, `rates`, or
|
||||
`timestamp` is missing or of the wrong runtime type, it SHALL raise
|
||||
`CurrencyProviderError(PARSE_ERROR)` with `providerName` populated.
|
||||
|
||||
#### Scenario: payload missing required field
|
||||
|
||||
- **WHEN** the injected fetch returns `Response(JSON.stringify({ base: "USD" }), 200)` (no `rates`, no `timestamp`)
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.PARSE_ERROR"`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"raises PARSE_ERROR when upstream payload is missing required fields"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-008 — Raises UPSTREAM_ERROR on 2xx error payload
|
||||
|
||||
The package SHALL detect the OXR error-payload shape (`{ error: true, status, message, description? }`)
|
||||
returned with a 2xx HTTP status and raise `CurrencyProviderError(UPSTREAM_ERROR)`
|
||||
with `upstreamMessage` set to the upstream `message` field and `status` set to
|
||||
the upstream `status` field.
|
||||
|
||||
#### Scenario: 2xx error payload surfaces UPSTREAM_ERROR
|
||||
|
||||
- **WHEN** the injected fetch returns `Response(JSON.stringify({ error: true, status: 401, message: "invalid_app_id", description: "..." }), 200)`
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UPSTREAM_ERROR"`, `upstreamMessage === "invalid_app_id"`, `status === 401`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"raises UPSTREAM_ERROR when OXR returns 2xx error payload"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-009 — Raises UNKNOWN_CURRENCY when target absent from payload
|
||||
|
||||
When `RateFetchOptions.targets` includes a code that is NOT present in the
|
||||
upstream `rates` map, the package SHALL raise
|
||||
`CurrencyProviderError(UNKNOWN_CURRENCY)` rather than silently dropping the
|
||||
target. The error message names the missing code.
|
||||
|
||||
#### Scenario: missing target raises UNKNOWN_CURRENCY
|
||||
|
||||
- **WHEN** `fetchRates({ targets: ["EUR", "ZZZ"] })` is called and upstream returns `{ EUR: 0.91 }`
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.UNKNOWN_CURRENCY"` and message containing `"ZZZ"`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"raises UNKNOWN_CURRENCY when options.targets contains a code absent from upstream payload"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-010 — Surfaces HTTP_ERROR for non-2xx via slim core
|
||||
|
||||
The package SHALL let `httpRequest()` surface `CurrencyProviderError(HTTP_ERROR)`
|
||||
for non-2xx upstream responses. The provider does not re-wrap the error;
|
||||
`providerName` is supplied to `httpRequest()` so the raised error carries it.
|
||||
|
||||
#### Scenario: non-2xx surfaces HTTP_ERROR with providerName
|
||||
|
||||
- **WHEN** the injected fetch returns `Response("", 503)`
|
||||
- **THEN** a `CurrencyProviderError` is raised with `code === "CURRENCY_PROVIDER.HTTP_ERROR"`, `status === 503`, `providerName === "open-exchange-rates"`
|
||||
|
||||
**Verified by:** `src/provider/provider.test.ts::"surfaces HTTP_ERROR with providerName on non-2xx upstream response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OXR-011 — Universal-tier purity and dependency invariants
|
||||
|
||||
The package SHALL be universal-tier — no Node-only or DOM-only globals are
|
||||
referenced. Runtime dependencies are limited to `@suaveplan/currency`,
|
||||
`@suaveplan/currency-providers`, `@suaveplan/error`, `@suaveplan/types`, and
|
||||
`tslib`. The `package.json` declares `"sideEffects": false`.
|
||||
|
||||
#### Scenario: dep-graph invariant holds
|
||||
|
||||
- **WHEN** `tests/dep-graph.test.ts` is executed
|
||||
- **THEN** the parsed `package.json` `dependencies` map contains exactly the four `@suaveplan/*` packages above plus `tslib`, no DOM-only globals are imported from `src/`, and `sideEffects === false`
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"declares the expected runtime dependencies"` AND `tests/dep-graph.test.ts::"declares sideEffects: false"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,66 @@
|
||||
# Tasks — add @suaveplan/currency-provider-open-exchange-rates
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticks.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/currency-providers.md` end-to-end
|
||||
- [x] 1.2 Read slim-core archive at `openspec/archive/2026-05-17-slim-currency-providers/`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/currency-provider-open-exchange-rates/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `package.json` with workspace deps on `@suaveplan/currency`, `@suaveplan/currency-providers`, `@suaveplan/error`, `@suaveplan/types`; `tslib` from catalog; `sideEffects: false`; subpath export for `./provider/provider`
|
||||
- [x] 2.2 `vite.config.ts`, `vitest.config.ts`, `tsconfig.json`, `tsconfig.lib.json` mirroring slim-core's shape
|
||||
- [x] 2.3 `tests/setup/polyfills.ts` placeholder
|
||||
- [x] 2.4 `src/provider/provider.ts` with `OpenExchangeRatesProvider` class and `OpenExchangeRatesProviderConfig` type
|
||||
- [x] 2.5 `src/index.ts` barrel re-exporting `OpenExchangeRatesProvider` and `OpenExchangeRatesProviderConfig`
|
||||
- [x] 2.6 Constructor validates `apiKey !== ""` → `MISSING_CONFIG`
|
||||
- [x] 2.7 `fetchRates()` rejects non-USD base when `allowNonUsdBase !== true` → `UNSUPPORTED_BASE`
|
||||
- [x] 2.8 `fetchRates()` routes through `httpRequest()` from slim core
|
||||
- [x] 2.9 Response parsing validates `{ base, rates, timestamp }` shape → `PARSE_ERROR` on mismatch
|
||||
- [x] 2.10 2xx error payload (`{ error: true, status, message }`) → `UPSTREAM_ERROR` with `upstreamMessage`
|
||||
- [x] 2.11 `options.targets` filter — missing target → `UNKNOWN_CURRENCY`
|
||||
- [x] 2.12 Timestamp `seconds * 1000` conversion
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/provider/provider.test.ts` covering every scenario in the spec delta
|
||||
- [x] 3.2 `tests/dep-graph.test.ts` pinning dep invariants
|
||||
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
- [x] 3.4 All test imports from `@suaveplan/testing/runner` (CLAUDE.md rule 11)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for `src/provider/provider.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 `README.md` at package root with `## Modules` linking sibling docs
|
||||
- [x] 4.3 Update `CLAUDE.md` package listing (added under finance family)
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/currency-provider-open-exchange-rates` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/currency-provider-open-exchange-rates` — 100/100/100/100
|
||||
- [ ] 5.4 Banned-pattern + barrel-shape + test-runner scans — zero output
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/currency-provider-open-exchange-rates` — green
|
||||
|
||||
## 6. Pre-archive 8-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` not required (no generic public surface)
|
||||
- [ ] 6.2 Zod schemas not required (provider parses upstream JSON inline with typed PARSE_ERROR)
|
||||
- [ ] 6.3 Every error code (`HTTP_ERROR`, `PARSE_ERROR`, `UPSTREAM_ERROR`, `MISSING_CONFIG`, `UNSUPPORTED_BASE`, `UNKNOWN_CURRENCY`) thrown by ≥1 production code path
|
||||
- [ ] 6.4 Strict-mode behaviors (free-tier base rejection) tested both on and off
|
||||
- [ ] 6.5 OTel — not in scope per `## Out of scope`; deferred to cross-cutting follow-up
|
||||
- [ ] 6.6 Lifecycle dedup — not in scope
|
||||
- [ ] 6.7 No retroactive tick
|
||||
- [ ] 6.8 No `export *` in feature barrels — only `index.ts` barrel exists (root) and uses explicit named exports
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/currency-provider-open-exchange-rates/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/add-currency-provider-open-exchange-rates/` → `openspec/archive/2026-05-17-add-currency-provider-open-exchange-rates/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md`
|
||||
Reference in New Issue
Block a user