diff --git a/DECISIONS.md b/DECISIONS.md index 858c911f..258d8237 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -272,3 +272,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data. [2026-07-21] Card-descriptor normalization keys on the pre-star merchant segment (post-star for processor prefixes) plus a token_subset match tier, instead of the deferred AI descriptor normalization (data_quality_master Appendix B): deterministic, mirrors into normalize_counterparty_key() so ledger-context template joins stay exact, and fixes the reported Anthropic no-signal case with no new infrastructure. Merchant history now falls back to description because card purchases never carry merchant_name. [2026-07-22] Issue #313 fix limited to the meals warning; left "Representationsgåvor max 180 kr" on the gåvor line untouched: scope rule (only the inverted-VAT claim and repealed ML 8:9 reference), even though the swedish-vat skill lists 300 SEK as the representationsgåvor base; flagged as follow-up in the PR. +[2026-07-22] LEGACY_DISCOVERY_HOSTS drift guard (#1093) is an exported validateLegacyDiscoveryHosts() returning a violations list, exercised only by a unit test that pins the registered prod config (app.accounted.se canonical + app.gnubok.se SKV pin), not a startup assertion: CI does not set the prod env vars, so a runtime assertion would either no-op in CI or crash self-hosted deploys with different domains; the test-pinned constants make any allowlist or pin change a deliberate, reviewed edit. diff --git a/lib/api/v1/__tests__/legacy-discovery-hosts.test.ts b/lib/api/v1/__tests__/legacy-discovery-hosts.test.ts new file mode 100644 index 00000000..3ae5eea8 --- /dev/null +++ b/lib/api/v1/__tests__/legacy-discovery-hosts.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' + +/** + * Drift guard for LEGACY_DISCOVERY_HOSTS (issue #1093). + * + * The constants below pin the configuration actually registered in + * production: the canonical app host after the accounted.se cutover, and + * the Skatteverket OAuth redirect_uri host registered in Utvecklarportalen + * (pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL on Vercel, kept on the legacy + * domain because the registration is slow to change). + * + * If either registration changes, update these constants in the same PR + * that changes LEGACY_DISCOVERY_HOSTS or the env pin. A red test here means + * the discovery allowlist and the registered OAuth configuration disagree, + * which in production surfaces as MCP clients re-authenticating against a + * mismatched issuer and AGI/moms staging failing silently. + */ +const CANONICAL = 'https://app.accounted.se' +const SKV_OAUTH_PIN = 'https://app.gnubok.se' + +/** + * CI does not set these env vars, so every test stubs them explicitly and + * re-imports the module to be robust against env reads being hoisted to + * module scope in a future refactor. + */ +async function loadValidator() { + vi.resetModules() + const mod = await import('../base-url') + return mod.validateLegacyDiscoveryHosts +} + +describe('validateLegacyDiscoveryHosts', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('passes for the registered production configuration', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', SKV_OAUTH_PIN) + const validate = await loadValidator() + expect(validate()).toEqual([]) + }) + + it('reports an allowlisted host orphaned from the registered configuration', async () => { + // Direction (b): with the SKV pin moved elsewhere and the canonical host + // on accounted.se, nothing registered accounts for app.gnubok.se anymore, + // so the validator must flag the allowlist entry instead of passing. + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.elsewhere.example') + const validate = await loadValidator() + const violations = validate() + expect( + violations.some((v) => v.startsWith('LEGACY_DISCOVERY_HOSTS entry "app.gnubok.se"')), + ).toBe(true) + }) + + it('reports a pinned SKV OAuth host that discovery would not reflect', async () => { + // Direction (a): the registered callback host must be either the + // canonical host or allowlisted, otherwise re-auth gets a wrong issuer. + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.elsewhere.example') + const validate = await loadValidator() + const violations = validate() + expect( + violations.some((v) => + v.startsWith('NEXT_PUBLIC_SKV_OAUTH_BASE_URL host "oauth.elsewhere.example"'), + ), + ).toBe(true) + }) + + it('accepts the SKV pin pointing at the canonical host', async () => { + // If Utvecklarportalen is ever re-registered on the canonical domain and + // the allowlist is trimmed in the same PR, the invariant holds; only the + // orphaned app.gnubok.se entry is reported until the allowlist catches up. + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', CANONICAL) + const validate = await loadValidator() + const violations = validate() + expect(violations.some((v) => v.startsWith('NEXT_PUBLIC_SKV_OAUTH_BASE_URL host'))).toBe( + false, + ) + expect( + violations.some((v) => v.startsWith('LEGACY_DISCOVERY_HOSTS entry "app.gnubok.se"')), + ).toBe(true) + }) + + it('accepts a pre-cutover configuration where the canonical host is the legacy host', async () => { + // Self-hosted or pre-cutover: NEXT_PUBLIC_APP_URL still on app.gnubok.se + // and no SKV pin set. The canonical host accounts for the allowlist entry. + vi.stubEnv('NEXT_PUBLIC_APP_URL', SKV_OAUTH_PIN) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', '') + const validate = await loadValidator() + expect(validate()).toEqual([]) + }) + + it('is case-insensitive and ignores a trailing slash on the pin URL', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://App.Gnubok.SE/') + const validate = await loadValidator() + expect(validate()).toEqual([]) + }) + + it('reports an unparseable NEXT_PUBLIC_SKV_OAUTH_BASE_URL instead of passing silently', async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', CANONICAL) + vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'not a url') + const validate = await loadValidator() + const violations = validate() + expect(violations.some((v) => v.includes('not a parseable URL'))).toBe(true) + }) +}) diff --git a/lib/api/v1/base-url.ts b/lib/api/v1/base-url.ts index 582680c0..80c2b281 100644 --- a/lib/api/v1/base-url.ts +++ b/lib/api/v1/base-url.ts @@ -34,6 +34,71 @@ export function getCanonicalBaseUrl(): string { */ const LEGACY_DISCOVERY_HOSTS = new Set(['app.gnubok.se']) +/** + * Guards LEGACY_DISCOVERY_HOSTS against drifting from the registered OAuth + * configuration (issue #1093). Two invariants, checked both ways: + * + * (a) When `NEXT_PUBLIC_SKV_OAUTH_BASE_URL` (the redirect_uri host + * registered with Skatteverket in Utvecklarportalen) is set, its host + * must be reflectable by discovery: either the canonical app host or a + * member of LEGACY_DISCOVERY_HOSTS. Otherwise MCP clients that + * re-authenticate through the pinned host receive an issuer that does + * not match, and AGI/moms staging fails silently. + * + * (b) Every member of LEGACY_DISCOVERY_HOSTS must be accounted for by the + * known registered configuration: the SKV OAuth pin host or the + * canonical app host. An orphan entry means someone added or kept a + * host that nothing registered actually uses. + * + * Returns human-readable violations; an empty array means the allowlist and + * the registered configuration agree. Called only from + * `__tests__/legacy-discovery-hosts.test.ts` (which pins the production + * registration), so drift is caught in CI instead of during a production + * re-auth near a filing deadline. No runtime behavior depends on it. + */ +export function validateLegacyDiscoveryHosts(): string[] { + const violations: string[] = [] + + let canonicalHost: string | null = null + try { + canonicalHost = new URL(getCanonicalBaseUrl()).host.toLowerCase() + } catch { + violations.push( + `NEXT_PUBLIC_APP_URL is not a parseable URL: "${process.env.NEXT_PUBLIC_APP_URL}"`, + ) + } + + const skvBase = process.env.NEXT_PUBLIC_SKV_OAUTH_BASE_URL?.trim() + let skvHost: string | null = null + if (skvBase) { + try { + skvHost = new URL(skvBase).host.toLowerCase() + } catch { + violations.push(`NEXT_PUBLIC_SKV_OAUTH_BASE_URL is not a parseable URL: "${skvBase}"`) + } + } + + if (skvHost && skvHost !== canonicalHost && !LEGACY_DISCOVERY_HOSTS.has(skvHost)) { + violations.push( + `NEXT_PUBLIC_SKV_OAUTH_BASE_URL host "${skvHost}" is neither the canonical host ` + + `("${canonicalHost}") nor in LEGACY_DISCOVERY_HOSTS; discovery would hand ` + + `re-authenticating clients a mismatched issuer`, + ) + } + + for (const legacyHost of LEGACY_DISCOVERY_HOSTS) { + if (legacyHost !== canonicalHost && legacyHost !== skvHost) { + violations.push( + `LEGACY_DISCOVERY_HOSTS entry "${legacyHost}" matches neither the canonical host ` + + `("${canonicalHost}") nor the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host ` + + `("${skvHost ?? 'unset'}"); it is orphaned from the registered configuration`, + ) + } + } + + return violations +} + export function resolveDiscoveryBaseUrl(request: Request): string { const canonical = getCanonicalBaseUrl() const host = request.headers.get('host')?.trim().toLowerCase()