diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d2c82d7..57a90e6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ record. no household mode. → [`PRODUCT.md`](PRODUCT.md) - **Schema migrations are mandatory, not optional.** `schemaVersion` plus ordered migration functions applied on load, because IndexedDB holds the - user's only copy of their financial data. Now at version 3. + user's only copy of their financial data. Now at version 4. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **A destructive write downloads a backup first.** Import and reset both export before replacing, and abort if the export fails. → commit `ac3d18a` @@ -129,6 +129,18 @@ record. chose. On a network failure the build falls back to committed constants marked `source: "manual"`, so Settings never claims a hardcoded number came from an API. → commit `b4e1933` +- **The rate service must be a host that answers directly, not one that + redirects.** `api.frankfurter.app` began 301-ing to `api.frankfurter.dev/v1`, + and a redirect response has to carry `Access-Control-Allow-Origin` itself for + a browser to follow it cross-origin — that 301 carries none, so "Fetch rates + now" died with only a CORS error to go on. The build-time fetch never noticed, + because a server-side fetch follows redirects freely. → commit history, and + `src/store/fxApi.ts` +- **The v3 -> v4 migration DROPS the stale URL rather than rewriting it** to the + new endpoint, so a dataset that stored it follows whatever the current default + is and the next move of the service needs no second migration for the same + user. A URL the owner chose themselves is left strictly alone. + → `src/store/migrations.ts` - **ODS export is generated client-side with `fflate`.** No server exists to generate it. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **Base path is a build-time variable, read through a `try/catch` accessor.** diff --git a/docs/TODO.md b/docs/TODO.md index f1cb7fd..a37e8f5 100755 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,13 +8,14 @@ decisions behind it are in `docs/specs/` — one spec per sub-project, each recording the alternatives that were rejected. Rules that outlived their task moved to `AGENTS.md`. -**Current state:** merged to `main`. 452 tests passing, +**Current state:** merged to `main`. 457 tests passing, `bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and offline paths. -**Schema is at version 3.** v1 -> v2 made allocation rules a dated series; -v2 -> v3 moved currencies into the dataset. Both migrations are -behaviour-preserving by construction and tested as such. +**Schema is at version 4.** v1 -> v2 made allocation rules a dated series; +v2 -> v3 moved currencies into the dataset; v3 -> v4 drops a stored rate-service +URL that can no longer work. All three are behaviour-preserving by construction +— not one changes a stored figure — and are tested as such. --- diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 36915e5..e507a56 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,7 +1,7 @@ import { SEED_CURRENCIES } from "./types.ts"; import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 3; +export const SCHEMA_VERSION = 4; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; diff --git a/src/store/fxApi.test.ts b/src/store/fxApi.test.ts index b4499bd..e9ba1a8 100644 --- a/src/store/fxApi.test.ts +++ b/src/store/fxApi.test.ts @@ -7,9 +7,19 @@ const ALLOWED = ["DKK", "USD", "EUR"]; describe("buildFxUrl", () => { test("substitutes the base and target placeholders", () => { expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe( - "https://api.frankfurter.app/latest?from=DKK&to=USD,EUR", + "https://api.frankfurter.dev/v1/latest?from=DKK&to=USD,EUR", ); }); + + // Regression. api.frankfurter.app answers every request with a 301 to + // api.frankfurter.dev/v1, and a redirect response must itself carry + // Access-Control-Allow-Origin for a browser to follow it cross-origin. That + // 301 carries no CORS headers at all, so the fetch was blocked before the + // redirect could be taken: "Fetch rates now" failed on the deployed site + // with a bare CORS error and no indication that the endpoint had moved. + test("does not point at the host that only redirects", () => { + expect(DEFAULT_FX_API_URL).not.toContain("frankfurter.app"); + }); }); describe("parseFxResponse", () => { diff --git a/src/store/fxApi.ts b/src/store/fxApi.ts index 9f050d5..5b3ef6d 100644 --- a/src/store/fxApi.ts +++ b/src/store/fxApi.ts @@ -1,7 +1,18 @@ import type { Currency, FxRate } from "../domain/types.ts"; -/** Frankfurter needs no API key. The URL is user-editable in settings. */ -export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}"; +/** + * Frankfurter needs no API key. The URL is user-editable in settings. + * + * Must be the `.dev/v1` host. `api.frankfurter.app` answers every request with + * a 301 here, and a redirect response has to carry Access-Control-Allow-Origin + * itself for a browser to follow it cross-origin — that 301 carries no CORS + * headers, so the request dies before the redirect is taken. + */ +export const DEFAULT_FX_API_URL = + "https://api.frankfurter.dev/v1/latest?from={base}&to={targets}"; + +/** The URL above until 2026-09; see the 3 -> 4 migration, which drops it. */ +export const STALE_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}"; export function buildFxUrl( template: string, diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 7649a72..1916d75 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -166,7 +166,7 @@ describe("v2 -> v3: currencies become data", () => { test("the three shipped currencies are added, with their decimals", () => { const migrated = migrate(v2()); - expect(migrated.settings.schemaVersion).toBe(3); + expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); expect(migrated.currencies).toEqual([ { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, @@ -196,7 +196,7 @@ describe("v2 -> v3: currencies become data", () => { expect(migrated.settings.foldStartMonth).toBe("2026-01"); }); - test("a v1 dataset migrates all the way through to v3", () => { + test("a v1 dataset migrates all the way through to the current version", () => { const v1 = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, fxRates: [], @@ -214,7 +214,7 @@ describe("v2 -> v3: currencies become data", () => { purchases: [], }; const migrated = migrate(v1); - expect(migrated.settings.schemaVersion).toBe(3); + expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); expect(migrated.posts[0]!.rules).toEqual([ { from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } }, ]); @@ -233,3 +233,41 @@ describe("v2 -> v3: currencies become data", () => { expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]); }); }); + +describe("v3 -> v4: the stale rate-service URL is dropped", () => { + const v3 = (fxApiUrl?: string) => ({ + settings: { + baseCurrency: "DKK", + foldStartMonth: "2026-01", + schemaVersion: 3, + ...(fxApiUrl === undefined ? {} : { fxApiUrl }), + }, + currencies: [{ code: "DKK", digits: 2 }], + fxRates: [], + posts: [], + months: [], + purchases: [], + }); + + const STALE = "https://api.frankfurter.app/latest?from={base}&to={targets}"; + + test("a dataset holding the dead default loses the field entirely", () => { + const out = migrate(v3(STALE)); + expect(out.settings.schemaVersion).toBe(4); + // Removed rather than rewritten to the new URL, so the dataset follows + // whatever the current default is and a future endpoint move needs no + // second migration for the same user. + expect("fxApiUrl" in out.settings).toBe(false); + }); + + test("a URL the owner chose themselves is left alone", () => { + const mine = "https://rates.example.com/v2?base={base}&want={targets}"; + expect(migrate(v3(mine)).settings.fxApiUrl).toBe(mine); + }); + + test("a dataset that never stored a URL is untouched apart from the version", () => { + const out = migrate(v3()); + expect("fxApiUrl" in out.settings).toBe(false); + expect(out.settings.schemaVersion).toBe(4); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index e5a99bd..f21a20a 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -1,5 +1,6 @@ import { SCHEMA_VERSION } from "../domain/seed.ts"; import { SEED_CURRENCIES } from "../domain/types.ts"; +import { STALE_FX_API_URL } from "./fxApi.ts"; import type { Dataset } from "../domain/types.ts"; export class UnsupportedSchemaError extends Error { @@ -56,6 +57,31 @@ const MIGRATIONS: Array<(data: any) => any> = [ ? data.currencies : SEED_CURRENCIES.map((currency) => ({ ...currency })), }), + + // 3 -> 4: drop a stored rate-service URL that can no longer work. + // api.frankfurter.app now answers every request with a 301 to + // api.frankfurter.dev/v1, and because that redirect carries no CORS headers + // a browser refuses to follow it — so "Fetch rates now" failed with nothing + // but a CORS error to go on. Changing the default fixes a dataset that never + // stored the URL; this fixes one that did. + // + // The field is REMOVED rather than rewritten to the new endpoint, so the + // dataset follows whatever the current default is and a future move of the + // service needs no second migration for the same user. A URL the owner chose + // themselves is left strictly alone: only the exact dead default is dropped. + // + // No stored figure changes, so this cannot alter a single number in the + // budget. + (data: any) => { + const { fxApiUrl, ...settings } = data.settings ?? {}; + return { + ...data, + settings: + fxApiUrl === STALE_FX_API_URL + ? { ...settings, schemaVersion: 4 } + : { ...data.settings, schemaVersion: 4 }, + }; + }, ]; export function migrate(raw: unknown): Dataset {