Add validateLegacyDiscoveryHosts() next to the allowlist in lib/api/v1/base-url.ts and a unit test that pins the registered production configuration (app.accounted.se canonical, app.gnubok.se SKV OAuth pin). The invariant is checked both ways: the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host must be reflectable by discovery (canonical or allowlisted), and every allowlist member must be accounted for by the registered configuration. Drift is now a red CI test instead of a silent production re-auth failure near a filing deadline. No runtime behavior changes. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
826513b4fd
commit
8294899543
@@ -272,3 +272,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user