6e8d76a9cb
The nightly skattekonto sync emailed "Skattekontot stämmer inte med bokföringen" whenever Skatteverket's saldo differed from BAS 1630 by more than 1 kr, every 24 hours while it lasted. On 2026-09-02 it fired on a 35 842 kr gap that the reconciliation explained to the last krona with 14 unbooked rows, while the Hem notice and the reconciliation page (both gated on unexplained_difference) said nothing was wrong. The check shipped in May 2026 (#525) before any in-app skattekonto view existed; the dashboard tile its comments promise was never built and the drift API route had no consumer. Since 2026-08-25 the reconciliation page and the Hem notice are the surface, with one definition of "stämmer inte". Removed: skattekonto-drift.ts, skattekonto-drift-email.ts, their tests, the skattekonto.drift_detected event type, the handler registration, the cron's drift hook, GET /api/extensions/skatteverket/skattekonto/drift, and the ROPA activity for the mail. The route is dropped from the ungated extension route allowlist to lock the ratchet. skattekonto_drift_tolerance stays: the Hem notice reads it. Stale skattekonto_drift_last_alert_at rows in extension_data are inert. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
114 lines
4.3 KiB
JavaScript
114 lines
4.3 KiB
JavaScript
/**
|
|
* Guards for the app/api/extensions carve-out.
|
|
*
|
|
* Physical routes under `app/api/extensions/<extensionId>/` are the ONE
|
|
* sanctioned place where app code may import from `@/extensions/` (see the
|
|
* "Check no core imports from extensions" step in
|
|
* .github/workflows/core-build.yml, which exempts exactly this directory).
|
|
* They exist because Vercel crons and OAuth callbacks need concrete file
|
|
* routes; the `ext/[...path]` dispatcher covers everything else.
|
|
*
|
|
* The carve-out has two invariants this guard enforces:
|
|
*
|
|
* 1. cross-extension-import (hard fail, count 0 today): a route under
|
|
* app/api/extensions/<id>/ may only import from its own extension,
|
|
* `@/extensions/<sector>/<id>/`. Reaching into another extension from
|
|
* here would create hidden coupling the registry cannot see.
|
|
*
|
|
* 2. ungated-extension-route (ratchet, may only shrink): these routes
|
|
* compile into EVERY build, including the core-with-zero-extensions CI
|
|
* build; disabling an extension in extensions.config.json only removes
|
|
* it from the runtime registry, never from the filesystem. So a route
|
|
* that executes extension code must first check
|
|
* `extensionRegistry.get('<id>')` (after loadExtensions()) and refuse
|
|
* when the extension is not registered: otherwise a disabled extension
|
|
* still exposes a live, invokable surface (this is exactly how the
|
|
* disabled push-notifications extension shipped an always-armed cron).
|
|
* Pre-existing routes are allowlisted below; the set may only shrink as
|
|
* gates are added. A NEW route must ship with the gate.
|
|
*/
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
/**
|
|
* Routes that predate the enablement-gate requirement. Remove an entry once
|
|
* its route checks `extensionRegistry.get('<id>')`; never add one.
|
|
*/
|
|
export const UNGATED_EXTENSION_ROUTES = new Set([
|
|
'app/api/extensions/cloud-backup/auto-sync/cron/route.ts',
|
|
'app/api/extensions/enable-banking/callback/route.ts',
|
|
'app/api/extensions/enable-banking/sync/cron/route.ts',
|
|
'app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts',
|
|
'app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts',
|
|
'app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts',
|
|
'app/api/extensions/stripe/callback/route.ts',
|
|
'app/api/extensions/stripe/sync/cron/route.ts',
|
|
'app/api/extensions/stripe/transactions/cron/route.ts',
|
|
])
|
|
|
|
const EXTENSION_IMPORT_RE = /from\s+['"]@\/extensions\/[^/'"]+\/([^/'"]+)\//g
|
|
|
|
function walkRouteFiles(dir, out = []) {
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return out
|
|
}
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name)
|
|
if (e.isDirectory()) {
|
|
if (e.name === '__tests__') continue
|
|
walkRouteFiles(full, out)
|
|
} else if (e.name === 'route.ts') {
|
|
out.push(full)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Scan app/api/extensions/<id>/ physical routes.
|
|
* Returns { crossImports: [{ file, imported }], ungated: [file] } with
|
|
* repo-relative, forward-slash paths.
|
|
*/
|
|
export function findExtensionRouteFindings(ROOT) {
|
|
const base = path.join(ROOT, 'app', 'api', 'extensions')
|
|
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/')
|
|
|
|
const crossImports = []
|
|
const ungated = []
|
|
|
|
for (const file of walkRouteFiles(base)) {
|
|
const relPath = rel(file)
|
|
const segments = relPath.split('/')
|
|
const extensionId = segments[3] // app/api/extensions/<id>/...
|
|
|
|
// The dispatcher and the generic dynamic-segment routes go through the
|
|
// registry already and import nothing from @/extensions/.
|
|
if (!extensionId || extensionId === 'ext' || extensionId.startsWith('[')) continue
|
|
|
|
const src = fs.readFileSync(file, 'utf8')
|
|
|
|
let importsOwnExtension = false
|
|
for (const match of src.matchAll(EXTENSION_IMPORT_RE)) {
|
|
if (match[1] === extensionId) {
|
|
importsOwnExtension = true
|
|
} else {
|
|
crossImports.push({ file: relPath, imported: match[1] })
|
|
}
|
|
}
|
|
|
|
if (!importsOwnExtension && crossImports.every((c) => c.file !== relPath)) continue
|
|
|
|
const hasGate =
|
|
src.includes(`extensionRegistry.get('${extensionId}')`) ||
|
|
src.includes(`extensionRegistry.get("${extensionId}")`)
|
|
if (!hasGate) ungated.push(relPath)
|
|
}
|
|
|
|
crossImports.sort((a, b) => a.file.localeCompare(b.file))
|
|
ungated.sort()
|
|
return { crossImports, ungated }
|
|
}
|