fix(fx): the rate service moved, and a browser cannot follow its redirect

"Fetch rates now" failed on the deployed site with nothing but a CORS
error. api.frankfurter.app now 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 request died before the
redirect could be taken.

Measured rather than guessed, with an Origin header set:

  api.frankfurter.app  ->  301, no access-control-* headers
  api.frankfurter.dev  ->  200, access-control-allow-origin: *

The response shape is unchanged, so parseFxResponse needed no edit.

Nothing was wrong at build time, which is why this went unnoticed: a
server-side fetch follows the 301 freely, so the baked rates were always
correct. Only the browser path was broken.

Two changes, because a new default alone fixes only some datasets:

- DEFAULT_FX_API_URL points at .dev/v1, with a test that fails if the
  redirecting host ever comes back.
- A 3 -> 4 migration drops settings.fxApiUrl when it holds exactly the
  dead default, which is the case for anyone who edited that field. It is
  DROPPED rather than rewritten, so the dataset 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 is left strictly
  alone. No stored figure changes, so it cannot alter a number in the
  budget.

Verified by clicking the button in a real browser: "Updated 2 rate(s)",
USD 6.456195 and EUR 7.474959 both stored with source "api" — and the USD
figure differs from the baked constant, so it is a genuine live fetch.

Two migration tests asserted the chain ends at 3; they now assert
SCHEMA_VERSION, since what they mean is "all the way through".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-02 22:20:43 +02:00
co-authored by Claude Opus 5
parent 61e7093c36
commit 0fcfdd27cd
7 changed files with 110 additions and 12 deletions
+13 -1
View File
@@ -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.**
+5 -4
View File
@@ -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.
---
+1 -1
View File
@@ -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;
+11 -1
View File
@@ -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", () => {
+13 -2
View File
@@ -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,
+41 -3
View File
@@ -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);
});
});
+26
View File
@@ -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 {