feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync (#2130)

* feat(skatteverket): ombudsregister grant verification, honest session expiry, daily ombud sync

Users reported the Skatteverket connection "just disappearing" with no
banner, needing BankID again every time. Two causes, both fixed here:

1. SKV's per-flow refresh token lives 65 minutes. /status and the
   skv_disconnected notice called any stored refresh token "refreshable",
   so a days-dead session reported healthy and the reconnect banner never
   fired until a submission failed live. lib/skatteverket/session-lifetime
   now decides refreshability (expires_at + 5 min, refresh cap) for both
   surfaces; the settings panel states the one-hour session lifetime.

2. The durable fix is the ombud (system certificate) path, dormant since
   July behind SKATTEVERKET_SYSTEM_AUTH_MODE. Skatteverket added scope
   `obr` (Ombudshantering v2) to our application id on 2026-09-01, so grant
   verification can now ask the ombudsregister instead of classifying 403s
   from the read services:
   - lib/ombud-client.ts: GET /ombud/autentisieratOmbud, GET /roller,
     POST .../djuplank/utseombud, on the system identity, per the public
     tjanstebeskrivning v2.0 (mirrored in dev_docs/skatteverket/ombudshantering).
     Role codes are env-pinned (SKATTEVERKET_OMBUD_ROLL_LASOMBUD/_MOMS) or
     matched on rollbeskrivning text; a deep link never mints with a
     guessed code.
   - grant-probe.ts: register first, read-service probes only as fallback.
   - New daily cron /api/extensions/skatteverket/ombud/sync/cron (30 3 * * *):
     one register call discovers every company that granted us, creates or
     downgrades connection rows by org number, runs from shadow mode on,
     and never mass-revokes on an empty register.
   - POST /system-connection/deeplink + "Utse {app} som ombud" button:
     the company lands in SKV's e-service with roles pre-selected.
   - Default system scopes include `obr`; skvRequestWithAuth gains an
     `accept` option (Ombudshantering requires the Accept header).

Still inert in prod until the org certificate and avtal land; the cron and
verify routes no-op while system auth is off or unconfigured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu

* fix(skatteverket): skeptic round on ombud sync, register 404 fallback, opt-in-only rows, mass-downgrade guard

Cron touches only existing connection rows (a tenant's own Verifiera or
deep-link opt-in; the deeplink route now records a pending row), so an
org-number twin never gets auto-verified, and rows the tenant revoked
locally stay revoked. A register 404 throws by default (spec: wrong URI)
and is empty only for the cron, which guards it. Decisions are planned
before any upsert; a run that would fully deny >= 3 rows and > 50% of the
granted ones applies no downgrade. Grants that classify as neither
behörighet are 'error', not 'denied'. Literal select in listConnections for
the phantom-column scanner. window.open without 'noopener' so the
pre-opened tab exists; opener nulled by hand. Deeplink route test added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu

* fix(skatteverket): CodeRabbit round: exact role labels, paginate connections, deny never-listed rows, fail deeplink without opt-in row

Role descriptions match the whole label so 'Momsdeklaration,
deklarationsombud' is never read as the narrow moms role. listConnections
pages through fetchAllRows on (created_at, id). A pending row the register
never lists is written once as denied instead of staying 'Inte verifierad'.
The deeplink route returns 500 when the opt-in row cannot be stored, and the
panel navigates in-tab when the pre-opened tab was blocked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu

* fix(skatteverket): fence ombud grants on contested org numbers; cron honours unrecognised role codes

An org number claimed by more than one live company is contested: verify
and deep link answer 409 ORG_NUMBER_CONTESTED and the nightly sync changes
nothing on it, so a tenant that typed a victim's public org number cannot
inherit the victim's grant. The sync also skips huvudmän whose register
roles classify as neither behörighet (pinning problem, never a denial),
mirroring probeViaOmbudsregister.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu

* fix(skatteverket): validate the ombud deep link host; withdraw grants on contested org numbers

The register's djuplank must be an https skatteverket.se URL before it is
returned or navigated to (the settings page follows it). The nightly sync
now withdraws a grant already recorded on an org number that more than one
live company claims, instead of only refusing new ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkLnhWnxt5wWB9j3vfMmxu

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 23:42:23 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent d8cf78330e
commit 8b09b06e14
24 changed files with 2292 additions and 54 deletions
@@ -0,0 +1,304 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() }))
const mockRegistryGet = vi.fn((_id: string): unknown => ({ id: 'skatteverket' }))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: { get: (id: string) => mockRegistryGet(id) },
}))
const mockVerifyCronSecret = vi.fn()
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: (...a: unknown[]) => mockVerifyCronSecret(...a),
}))
const mockMode = vi.fn()
const mockConfigured = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/system-auth/config', () => ({
getSystemAuthMode: () => mockMode(),
isSystemAuthConfigured: () => mockConfigured(),
}))
vi.mock('@/extensions/general/skatteverket/lib/resolve-auth', () => ({
currentSkvEnvironment: () => 'test',
}))
const mockListConnections = vi.fn()
const mockRecordProbeResult = vi.fn()
const mockContested = vi.fn(async (): Promise<Set<string>> => new Set())
vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({
listConnections: (...a: unknown[]) => mockListConnections(...a),
recordProbeResult: (...a: unknown[]) => mockRecordProbeResult(...a),
findContestedOrgNumbers: () => mockContested(),
}))
const mockListOmbudGrants = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/ombud-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
listOmbudGrants: (...a: unknown[]) => mockListOmbudGrants(...a),
}
})
import { GET } from '../route'
const ENV_KEYS = ['SKATTEVERKET_ENABLED']
let savedEnv: Record<string, string | undefined>
type Status = 'unknown' | 'granted' | 'denied' | 'error'
function connection(
companyId: string,
orgNumber: string,
lasombud: Status = 'granted',
moms: Status = 'granted',
status = 'verified'
) {
return {
id: `conn-${companyId}`,
company_id: companyId,
environment: 'test',
org_number: orgNumber,
status,
lasombud_status: lasombud,
moms_ombud_status: moms,
}
}
const JLO = (huvudman: string) => ({
huvudman,
roll: 'JLO',
rollbeskrivning: 'Juridiskt läsombud',
giltigFrom: '2026-07-19',
})
const MOMS = (huvudman: string) => ({
huvudman,
roll: 'MOMS',
rollbeskrivning: 'Momsdeklaration, ombud',
giltigFrom: '2026-07-19',
})
beforeEach(() => {
vi.clearAllMocks()
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]))
process.env.SKATTEVERKET_ENABLED = 'true'
mockVerifyCronSecret.mockReturnValue(null)
mockMode.mockReturnValue('shadow')
mockConfigured.mockReturnValue(true)
mockListConnections.mockResolvedValue([])
mockRecordProbeResult.mockResolvedValue({ id: 'conn' })
mockContested.mockResolvedValue(new Set())
mockListOmbudGrants.mockResolvedValue([])
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k]
else process.env[k] = savedEnv[k]
}
})
const request = () => new Request('http://localhost/api/extensions/skatteverket/ombud/sync/cron')
const recordedFor = (companyId: string) =>
mockRecordProbeResult.mock.calls.map((c) => c[0]).filter((input) => input.companyId === companyId)
describe('GET /api/extensions/skatteverket/ombud/sync/cron', () => {
it('401 without the cron secret', async () => {
mockVerifyCronSecret.mockReturnValue(new Response('nope', { status: 401 }))
const res = await GET(request())
expect(res.status).toBe(401)
expect(mockListOmbudGrants).not.toHaveBeenCalled()
})
it('503 EXTENSION_DISABLED when the skatteverket extension is not in the registry', async () => {
mockRegistryGet.mockReturnValueOnce(undefined)
const res = await GET(request())
expect(res.status).toBe(503)
expect(await res.json()).toMatchObject({ code: 'EXTENSION_DISABLED' })
expect(mockListOmbudGrants).not.toHaveBeenCalled()
})
it('no-ops when the extension is disabled or system auth is off/unconfigured', async () => {
process.env.SKATTEVERKET_ENABLED = 'false'
expect(await (await GET(request())).json()).toMatchObject({ processed: 0 })
process.env.SKATTEVERKET_ENABLED = 'true'
mockMode.mockReturnValue('off')
expect(await (await GET(request())).json()).toMatchObject({ message: 'System auth not active' })
mockMode.mockReturnValue('on')
mockConfigured.mockReturnValue(false)
expect(await (await GET(request())).json()).toMatchObject({ message: 'System auth not active' })
expect(mockListOmbudGrants).not.toHaveBeenCalled()
})
it('502 when the register cannot be read, without touching any row', async () => {
mockListOmbudGrants.mockRejectedValue(new Error('down'))
const res = await GET(request())
expect(res.status).toBe(502)
expect(mockRecordProbeResult).not.toHaveBeenCalled()
})
it('asks the register with the cron-only empty-on-404 option', async () => {
await GET(request())
expect(mockListOmbudGrants).toHaveBeenCalledWith({}, { emptyOn404: true })
})
it('records grants only on rows that already exist (tenant opt-in); a listed huvudman without a row is ignored', async () => {
mockListConnections.mockResolvedValue([
connection('c-pending', '165560000000', 'unknown', 'unknown', 'pending'),
connection('c-partial', '195001011234', 'denied', 'denied', 'pending'),
])
mockListOmbudGrants.mockResolvedValue([
JLO('165560000000'),
MOMS('165560000000'),
{ ...JLO('195001011234'), roll: 'DEKL', rollbeskrivning: 'Deklarationsombud' },
MOMS('195001011234'),
JLO('165550000000'), // no row for this org number: the twin/unmatched case
])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ registryHuvudman: 3, rows: 2, granted: 2, denied: 0, revoked: 0, guardTripped: false })
expect(mockRecordProbeResult).toHaveBeenCalledTimes(2)
expect(recordedFor('c-pending')[0]).toMatchObject({
environment: 'test',
orgNumber: '165560000000',
lasombud: expect.objectContaining({ status: 'granted' }),
momsOmbud: expect.objectContaining({ status: 'granted' }),
})
expect(recordedFor('c-partial')[0]).toMatchObject({
lasombud: expect.objectContaining({ status: 'denied' }),
momsOmbud: expect.objectContaining({ status: 'granted' }),
})
// Never creates a row for the unmatched huvudman.
expect(mockRecordProbeResult.mock.calls.some((c) => c[0].orgNumber === '165550000000')).toBe(false)
})
it('skips rows the tenant revoked locally even though the grant still stands at Skatteverket', async () => {
mockListConnections.mockResolvedValue([connection('c-off', '165560000000', 'unknown', 'unknown', 'revoked')])
mockListOmbudGrants.mockResolvedValue([JLO('165560000000'), MOMS('165560000000')])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ rows: 0 })
expect(mockRecordProbeResult).not.toHaveBeenCalled()
})
it('leaves unchanged rows alone, records denial once for a never-listed pending row, downgrades one revoked-at-SKV row', async () => {
mockListConnections.mockResolvedValue([
connection('c-keep', '165560000000'),
connection('c-keep2', '165570000000'),
connection('c-keep3', '165580000000'),
connection('c-gone', '165590000000'),
connection('c-never', '165500000000', 'denied', 'denied', 'pending'),
connection('c-fresh', '165510000000', 'unknown', 'unknown', 'pending'),
])
mockListOmbudGrants.mockResolvedValue([
JLO('165560000000'), MOMS('165560000000'),
JLO('165570000000'), MOMS('165570000000'),
JLO('165580000000'), MOMS('165580000000'),
])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ unchanged: 4, revoked: 1, denied: 1, granted: 0, guardTripped: false })
expect(mockRecordProbeResult).toHaveBeenCalledTimes(2)
expect(recordedFor('c-gone')[0]).toMatchObject({
orgNumber: '165590000000',
lasombud: { status: 'denied', detail: expect.stringContaining('huvudman saknas') },
momsOmbud: { status: 'denied' },
})
// The deep-link row nobody signed yet: written once as "Saknas", not left "Inte verifierad".
expect(recordedFor('c-fresh')[0]).toMatchObject({ lasombud: { status: 'denied' }, momsOmbud: { status: 'denied' } })
expect(recordedFor('c-never')).toHaveLength(0)
})
it('a failed upsert is counted once and never turns into a downgrade', async () => {
mockListConnections.mockResolvedValue([connection('c-1', '165560000000', 'unknown', 'unknown', 'pending')])
mockListOmbudGrants.mockResolvedValue([JLO('165560000000')])
mockRecordProbeResult.mockResolvedValue(null)
const body = await (await GET(request())).json()
expect(body).toMatchObject({ failed: 1, revoked: 0, granted: 0 })
expect(mockRecordProbeResult).toHaveBeenCalledTimes(1)
})
it('empty-register guard: no grants while rows exist downgrades nothing', async () => {
mockListOmbudGrants.mockResolvedValue([])
mockListConnections.mockResolvedValue([connection('c-1', '165560000000')])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ registryHuvudman: 0, revoked: 0, skipped: 1, guardTripped: true, guardReason: 'empty_register' })
expect(mockRecordProbeResult).not.toHaveBeenCalled()
})
it('mass-downgrade guard: a run that would deny most granted rows applies no downgrade, but still records upgrades', async () => {
// Four granted rows; the register (say, with a mistyped pinned code) lists
// only one of them, plus a pending row that did get its grant.
mockListConnections.mockResolvedValue([
connection('c-1', '165510000000'),
connection('c-2', '165520000000'),
connection('c-3', '165530000000'),
connection('c-4', '165540000000'),
connection('c-new', '165550000000', 'unknown', 'unknown', 'pending'),
])
mockListOmbudGrants.mockResolvedValue([JLO('165510000000'), MOMS('165510000000'), JLO('165550000000'), MOMS('165550000000')])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ guardTripped: true, guardReason: 'mass_downgrade', revoked: 0, skipped: 3, granted: 1, unchanged: 1 })
expect(mockRecordProbeResult).toHaveBeenCalledTimes(1)
expect(recordedFor('c-new')[0]).toMatchObject({ lasombud: { status: 'granted' } })
})
it('a contested org number (two live companies claim it) is never granted, and an existing grant on it is withdrawn', async () => {
mockListConnections.mockResolvedValue([
connection('c-victim', '165560000000'), // verified before the twin appeared
connection('c-twin', '165560000000', 'unknown', 'unknown', 'pending'),
connection('c-clear', '165570000000', 'unknown', 'unknown', 'pending'),
])
mockContested.mockResolvedValue(new Set(['165560000000']))
mockListOmbudGrants.mockResolvedValue([JLO('165560000000'), MOMS('165560000000'), JLO('165570000000')])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ contested: 2, revoked: 1, granted: 1, guardTripped: false })
expect(mockRecordProbeResult).toHaveBeenCalledTimes(2)
expect(recordedFor('c-clear')[0]).toMatchObject({ lasombud: { status: 'granted' } })
expect(recordedFor('c-victim')[0]).toMatchObject({
lasombud: { status: 'denied', detail: expect.stringContaining('fler än ett företag') },
momsOmbud: { status: 'denied' },
})
expect(recordedFor('c-twin')).toHaveLength(0)
})
it('a huvudman listed with only unrecognised role codes is neither granted nor denied (pinning problem)', async () => {
mockListConnections.mockResolvedValue([connection('c-1', '165560000000')])
mockListOmbudGrants.mockResolvedValue([
{ huvudman: '165560000000', roll: 'ZZ9', rollbeskrivning: 'Läsombud, juridisk person', giltigFrom: '2026-07-19' },
])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ unrecognized: 1, revoked: 0, denied: 0, guardTripped: false })
expect(mockRecordProbeResult).not.toHaveBeenCalled()
})
it('mass-downgrade guard needs at least three planned downgrades', async () => {
mockListConnections.mockResolvedValue([
connection('c-1', '165510000000'),
connection('c-2', '165520000000'),
])
mockListOmbudGrants.mockResolvedValue([JLO('165510000000'), MOMS('165510000000')])
const body = await (await GET(request())).json()
expect(body).toMatchObject({ guardTripped: false, revoked: 1 })
})
})
@@ -0,0 +1,273 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
import {
findContestedOrgNumbers,
listConnections,
recordProbeResult,
type GrantStatus,
type SkvCompanyConnection,
} from '@/extensions/general/skatteverket/lib/connection-store'
import {
isoDate,
listOmbudGrants,
summarizeGrants,
type HuvudmanGrantSummary,
} from '@/extensions/general/skatteverket/lib/ombud-client'
ensureInitialized()
export const maxDuration = 60
const TIME_BUDGET_MS = 50_000
/**
* Downgrade guard: a single run may not turn more than this share of the
* currently granted rows into fully denied ones. A mistyped pinned role code,
* a renamed rollbeskrivning, or a partial register response would otherwise
* deny every company in one night and silently push their background reads
* back onto 65-minute personal tokens. Below MIN_ROWS the share test is
* meaningless (one of two rows is 50%), so small fleets are guarded by the
* absolute floor instead.
*/
export const MASS_DOWNGRADE_MIN_ROWS = 3
export const MASS_DOWNGRADE_MAX_SHARE = 0.5
/**
* GET /api/extensions/skatteverket/ombud/sync/cron
*
* Daily ombudsregister sync (cron 30 3 * * *, half an hour before the
* skattekonto sync so a grant signed yesterday is used this morning).
*
* Companies appoint Accounted as ombud in Skatteverket's e-service, and
* nothing calls us back. One call to Ombudshantering v2
* (GET /ombud/autentisieratOmbud on the system identity) lists every huvudman
* that granted Accounted anything, and this route reconciles that list
* against the company's OWN connection row:
*
* - only rows that already exist are touched. A row exists because a
* member of that company pressed Verifiera or minted the deep link: the
* tenant's explicit opt-in. Rows are never created here,
* - an org number claimed by more than one live company is contested:
* no row on it is granted or changed (org-number reuse is allowed and
* org numbers are public, so a tenant that typed a victim's number must
* not inherit the victim's grant; see findContestedOrgNumbers),
* - rows marked 'revoked' by the tenant's own "Koppla från" stay revoked
* until a member re-verifies; a still-standing grant at Skatteverket is
* not permission to switch the row back on,
* - a listed huvudman: granted/denied per behörighet from its active roles,
* - an unlisted huvudman: both behörigheter denied (withdrawn or expired),
* subject to the downgrade guards below.
*
* Runs in shadow mode as well as on: it only records grant state, never
* changes which credentials a read uses (that policy lives in
* resolveReadAuth). Off mode, or an unconfigured system flow, is a no-op.
*
* Downgrade guards: (1) an empty register while rows exist downgrades
* nothing (a wrong base URL or a "wrong URI" 404 must not mass-revoke);
* (2) a run that would fully deny more than MASS_DOWNGRADE_MAX_SHARE of the
* currently granted rows (and at least MASS_DOWNGRADE_MIN_ROWS of them)
* applies no downgrades at all and reports guardTripped, so a classification
* or partial-response problem surfaces in the logs instead of in every
* company's settings.
*/
export async function GET(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
// Physical routes under app/api/extensions/<id>/ compile into every build,
// including core-with-zero-extensions: the registry is what switches the
// extension on, so a disabled extension must refuse visibly (503).
loadExtensions()
if (!extensionRegistry.get('skatteverket')) {
return NextResponse.json(
{ error: 'Skatteverket extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 }
)
}
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
}
if (getSystemAuthMode() === 'off' || !isSystemAuthConfigured()) {
return NextResponse.json({ message: 'System auth not active', processed: 0 })
}
const startedAt = Date.now()
const environment = currentSkvEnvironment()
const today = isoDate(new Date())
let grants: Map<string, HuvudmanGrantSummary>
try {
grants = summarizeGrants(await listOmbudGrants({}, { emptyOn404: true }), today)
} catch (error) {
console.error('[ombud-sync-cron] ombudsregister lookup failed', {
message: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ error: 'Ombudsregister lookup failed' }, { status: 502 })
}
const rows = (await listConnections(environment)).filter((row) => row.status !== 'revoked')
const contested = await findContestedOrgNumbers()
const decisions = planDecisions(rows, grants, today, contested)
const grantedNow = rows.filter(isAnyGranted).length
const fullDowngrades = decisions.filter((d) => d.kind === 'downgrade').length
const emptyRegisterGuard = grants.size === 0 && rows.length > 0
const massDowngradeGuard =
fullDowngrades >= MASS_DOWNGRADE_MIN_ROWS && fullDowngrades > grantedNow * MASS_DOWNGRADE_MAX_SHARE
const guardTripped = emptyRegisterGuard || massDowngradeGuard
if (guardTripped) {
console.warn('[ombud-sync-cron] downgrade guard tripped; no row is downgraded this run', {
environment,
registryHuvudman: grants.size,
rows: rows.length,
grantedNow,
plannedDowngrades: fullDowngrades,
reason: emptyRegisterGuard ? 'empty_register' : 'mass_downgrade',
})
}
const counts = { granted: 0, denied: 0, revoked: 0, unchanged: 0, contested: 0, unrecognized: 0, failed: 0, skipped: 0 }
for (const decision of decisions) {
if (Date.now() - startedAt > TIME_BUDGET_MS) {
counts.skipped += 1
continue
}
if (decision.kind === 'downgrade' && guardTripped) {
counts.skipped += 1
continue
}
if (decision.kind === 'contested') {
counts.contested += 1
if (!decision.lasombud || !decision.momsOmbud) continue
const withdrawn = await recordProbeResult({
companyId: decision.row.company_id,
environment,
orgNumber: decision.row.org_number,
lasombud: { status: decision.lasombud, detail: decision.detail },
momsOmbud: { status: decision.momsOmbud, detail: decision.detail },
error: null,
})
if (withdrawn) counts.revoked += 1
else counts.failed += 1
continue
}
if (decision.kind === 'unrecognized') {
counts.unrecognized += 1
continue
}
if (decision.kind === 'unchanged') {
counts.unchanged += 1
continue
}
const recorded = await recordProbeResult({
companyId: decision.row.company_id,
environment,
orgNumber: decision.row.org_number,
lasombud: { status: decision.lasombud, detail: decision.detail },
momsOmbud: { status: decision.momsOmbud, detail: decision.detail },
error: null,
})
if (!recorded) {
counts.failed += 1
continue
}
if (decision.kind === 'downgrade') counts.revoked += 1
else if (decision.lasombud === 'granted' || decision.momsOmbud === 'granted') counts.granted += 1
else counts.denied += 1
}
return NextResponse.json({
environment,
registryHuvudman: grants.size,
rows: rows.length,
...counts,
guardTripped,
guardReason: emptyRegisterGuard ? 'empty_register' : massDowngradeGuard ? 'mass_downgrade' : null,
durationMs: Date.now() - startedAt,
})
}
function isAnyGranted(row: SkvCompanyConnection): boolean {
return row.lasombud_status === 'granted' || row.moms_ombud_status === 'granted'
}
type Decision =
| { kind: 'unchanged'; row: SkvCompanyConnection }
/**
* More than one live company claims this org number: nobody gets granted
* on it, and a row that already holds a grant has it withdrawn (the
* denied statuses are present exactly then).
*/
| {
kind: 'contested'
row: SkvCompanyConnection
lasombud?: GrantStatus
momsOmbud?: GrantStatus
detail?: string
}
/** The register lists roles we cannot name (codes unpinned/renamed): never a denial. */
| { kind: 'unrecognized'; row: SkvCompanyConnection }
| {
/** 'record' updates from the register; 'downgrade' is a granted row that would lose every grant. */
kind: 'record' | 'downgrade'
row: SkvCompanyConnection
lasombud: GrantStatus
momsOmbud: GrantStatus
detail: string
}
/**
* What the register says about each opted-in row, without writing anything:
* the guards need the whole picture before the first upsert.
*/
export function planDecisions(
rows: SkvCompanyConnection[],
grants: Map<string, HuvudmanGrantSummary>,
today: string,
contested: Set<string> = new Set()
): Decision[] {
const decisions: Decision[] = []
for (const row of rows) {
if (contested.has(row.org_number)) {
// A contested number is frozen in both directions: never granted, and
// any grant already recorded on it is withdrawn until support settles
// which tenant the org number belongs to. Withdrawn outside the
// downgrade guards on purpose: this is the guard.
if (isAnyGranted(row)) {
const detail = `ombudsregister ${today}: organisationsnumret används av fler än ett företag`
decisions.push({ kind: 'contested', row, lasombud: 'denied', momsOmbud: 'denied', detail })
} else {
decisions.push({ kind: 'contested', row })
}
continue
}
const summary = grants.get(row.org_number)
if (summary && summary.roles.length > 0 && !summary.recognized) {
// Same rule as probeViaOmbudsregister: unknown role codes are a
// pinning problem on our side, not a company withdrawing anything.
decisions.push({ kind: 'unrecognized', row })
continue
}
const lasombud: GrantStatus = summary?.lasombud ? 'granted' : 'denied'
const momsOmbud: GrantStatus = summary?.moms_ombud ? 'granted' : 'denied'
const detail = summary
? `ombudsregister ${today}: roller ${summary.roles.join(', ') || 'inga'}`
: `ombudsregister ${today}: huvudman saknas`
// A row already showing exactly this state is left alone, so a never-listed
// company is written once (unknown -> denied, "Saknas" in settings) and
// then skipped every following night.
if (row.lasombud_status === lasombud && row.moms_ombud_status === momsOmbud) {
decisions.push({ kind: 'unchanged', row })
continue
}
const losesEverything = isAnyGranted(row) && lasombud === 'denied' && momsOmbud === 'denied'
decisions.push({ kind: losesEverything ? 'downgrade' : 'record', row, lasombud, momsOmbud, detail })
}
return decisions
}