Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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 -->

View File

@@ -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`