Enforce MFA on critical mutation routes + post-audit foundation (A1) (#646)

* feat(lib): add canonical money + format + fetch primitives (audit Tier 0)

Foundation for post-audit cleanup: shared primitives so subsequent refactors import one helper instead of reinventing (the duplication the audit found).

- lib/money.ts: canonical roundOre/ORE_TOLERANCE (+ equalOre/isZeroOre/sumOre); lib/bokslut/rounding.ts re-exports for back-compat
- lib/utils.ts: formatAmount, formatWholeKr, formatDateTime
- lib/hooks/use-fetch.ts: generic client fetch hook (abort, bilingual errors, refetch)
- components/common/DataState.tsx: loading/error/empty wrapper over Skeleton/EmptyState
- messages: common.retry / common.load_error (sv+en)
- tests: 16 tests incl. the 1.005 half-ore case and locale-robust format assertions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(guards): ratchet against new MFA-bypassing routes and naive ore-rounding

Adds scripts/checks/no-new-antipatterns.mjs + committed baseline. Fails CI only when a PR ADDS a route hand-rolling supabase.auth.getUser() (which skips MFA AAL2 enforcement) or a new Math.round(x*100)/100. Baseline: 178 raw-auth routes, 668 naive rounds — ratchets down as the A1 (route-auth) and D1 (rounding) migrations land. Wired into core-build.yml; green at baseline.

Note: scripts/ is gitignored (.gitignore:70 '/scripts') yet tracks 39 files via force-add; these two were force-added to match that existing pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api,errors): enforce MFA on journal-entry mutation routes via withRouteContext (A1)

Migrates the 4 journal-entry mutation routes (commit, correct, reverse, recordate) off hand-rolled supabase.auth.getUser() onto withRouteContext, which enforces MFA AAL2 (requireAuth) + non-viewer role (requireWrite) and routes thrown errors through the canonical errorResponse envelope. Fixes audit finding A1 for the most compliance-critical mutations and folds in C8 for these routes (drops bookkeepingErrorResponse; they now emit message_en).

Also fixes a latent bug: errorResponse()/extractBookkeepingDetails only handled 11 of 15 typed bookkeeping errors, so MeaninglessCorrection / NoOpenPeriodForDate / TargetPeriodClosed / TargetPeriodLocked silently degraded to a generic 500 (affecting existing v1 callers too). Adds the 4 missing registry codes + extract cases -> correct 400/409.

Behavior change: untyped engine throws now return the canonical 500 envelope instead of 400+raw-string; typed errors keep their status (verified against the registry). Tests updated to the realistic typed-error contract + a 403 write-gate test on commit. Updates .claude/rules/api-routes.md to prescribe withRouteContext. Ratchets the antipattern guard 178 -> 174. Full unit suite green (5023); tsc: no new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): enforce MFA on salary run authorization routes via withRouteContext (A1)

Migrates the salary-run lifecycle write routes (approve, paid, revert) — the highest-PII A1 surface — off hand-rolled supabase.auth.getUser() onto withRouteContext (enforces MFA AAL2 + non-viewer role). Explicit { error } returns are preserved unchanged (passed through the wrapper); only auth changes, so no error-shape regression. Salary unit suite green (8). Ratchets the antipattern guard 174 -> 171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: address PR #646 bot findings

- guard: match withRouteContext/requireAuth at the CALL site (withRouteContext[<(]), not a bare import — closes the false-negative greptile flagged. It surfaced app/api/sandbox/seed (hand-rolled getUser; the loose regex had matched a code comment). Switched that route to requireAuth() — the documented stopgap for routes that can't use withRouteContext (it runs before a company exists; anonymous users, so MFA is a no-op but the auth path is now consistent). Guard stays at 171.
- money.test: add the negative half-ore case roundOre(-1.005) === -1 to lock the rounding direction against regressions.
- use-fetch: document keep-previous-data + deferred-loading (effect-tick) semantics.
- structured-errors: drop the BFL 5 kap. 5 § citation from MEANINGLESS_CORRECTION per the swedish-compliance bot (5 § governs correction procedure, not the no-op precondition).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: enrich wrapper error logging + document sandbox GDPR controls (PR #646)

- with-route-context: log unhandled errors and route errorResponse through the resolved { userId, companyId } logger, not just { requestId, operation } — closes the OWASP V16 audit-trail finding for all 82+ routes using the wrapper. Documented in the JSDoc.
- sandbox/seed: document the GDPR Art.32 compensating controls for the anonymous write path (anonymous-only, /24 rate limit, synthetic demo data, own-company RLS scope). No functional change — the flagged behaviour is pre-existing by design; this records the reasoning inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-03 15:34:58 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c74b19df1b
commit 0b86901a2b
28 changed files with 1102 additions and 380 deletions
+182
View File
@@ -0,0 +1,182 @@
{
"_comment": "Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).",
"rawRouteAuth": {
"count": 171,
"files": [
"app/api/account/delete/route.ts",
"app/api/account/password/route.ts",
"app/api/agent/composer/route.ts",
"app/api/agent/conversations/[id]/route.ts",
"app/api/agent/conversations/route.ts",
"app/api/agent/invoke/route.ts",
"app/api/agent/memory/[id]/route.ts",
"app/api/agent/memory/route.ts",
"app/api/agent/onboarding/stream/route.ts",
"app/api/agent/profile/route.ts",
"app/api/agent/profile/verify/route.ts",
"app/api/agent/skills/route.ts",
"app/api/audit-trail/route.ts",
"app/api/bookkeeping/account-balances/route.ts",
"app/api/bookkeeping/account-totals/route.ts",
"app/api/bookkeeping/accounts/[number]/route.ts",
"app/api/bookkeeping/accounts/activate/route.ts",
"app/api/bookkeeping/accounts/bas-lookup/route.ts",
"app/api/bookkeeping/accounts/reference/route.ts",
"app/api/bookkeeping/accounts/route.ts",
"app/api/bookkeeping/fiscal-periods/[id]/close/route.ts",
"app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts",
"app/api/bookkeeping/fiscal-periods/[id]/route.ts",
"app/api/bookkeeping/fiscal-periods/period-status/route.ts",
"app/api/bookkeeping/fiscal-periods/route.ts",
"app/api/bookkeeping/journal-entries/[id]/chain/route.ts",
"app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts",
"app/api/bookkeeping/journal-entries/[id]/notes/route.ts",
"app/api/bookkeeping/journal-entries/[id]/route.ts",
"app/api/bookkeeping/journal-entries/route.ts",
"app/api/bookkeeping/mapping-rules/evaluate/route.ts",
"app/api/bookkeeping/mapping-rules/route.ts",
"app/api/bookkeeping/no-doc-required/route.ts",
"app/api/bookkeeping/voucher-gaps/route.ts",
"app/api/calendar/feed/route.ts",
"app/api/cash-accounts/route.ts",
"app/api/company/check-org-number/route.ts",
"app/api/company/current/route.ts",
"app/api/company/members/[id]/route.ts",
"app/api/company/members/invite/[id]/route.ts",
"app/api/company/members/invite/route.ts",
"app/api/company/members/route.ts",
"app/api/company/route.ts",
"app/api/currency/rate/route.ts",
"app/api/deadlines/[id]/complete/route.ts",
"app/api/deadlines/[id]/route.ts",
"app/api/deadlines/[id]/status/route.ts",
"app/api/deadlines/route.ts",
"app/api/documents/[id]/extraction-status/route.ts",
"app/api/documents/[id]/route.ts",
"app/api/documents/[id]/verify/route.ts",
"app/api/documents/[id]/versions/route.ts",
"app/api/documents/counts/route.ts",
"app/api/events/route.ts",
"app/api/extensions/[sector]/[slug]/data/route.ts",
"app/api/extensions/[sector]/[slug]/settings/route.ts",
"app/api/extensions/ext/[...path]/route.ts",
"app/api/extensions/skatteverket/skattekonto/drift/route.ts",
"app/api/import/sie/[id]/route.ts",
"app/api/import/sie/create-accounts/route.ts",
"app/api/import/sie/mappings/route.ts",
"app/api/import/sie/route.ts",
"app/api/invoices/[id]/convert/route.ts",
"app/api/invoices/[id]/mark-sent/route.ts",
"app/api/invoices/[id]/pdf/route.ts",
"app/api/invoices/[id]/route.ts",
"app/api/invoices/preview-pdf/route.ts",
"app/api/kpi/preferences/route.ts",
"app/api/mcp-oauth/authorize/route.ts",
"app/api/pending-operations/[id]/commit/route.ts",
"app/api/pending-operations/[id]/reject/route.ts",
"app/api/pending-operations/[id]/route.ts",
"app/api/pending-operations/bulk-commit/route.ts",
"app/api/pending-operations/route.ts",
"app/api/reconciliation/bank/link/route.ts",
"app/api/reconciliation/bank/mark-opening-balance/route.ts",
"app/api/reconciliation/bank/run/route.ts",
"app/api/reconciliation/bank/status/route.ts",
"app/api/reconciliation/bank/unlink/route.ts",
"app/api/reconciliation/bank/unmatched-entries/route.ts",
"app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts",
"app/api/reports/ar-ledger/route.ts",
"app/api/reports/ar-ledger/xlsx/route.ts",
"app/api/reports/audit-trail/route.ts",
"app/api/reports/avgifter-basis/route.ts",
"app/api/reports/balance-sheet/pdf/route.ts",
"app/api/reports/balance-sheet/xlsx/route.ts",
"app/api/reports/balansrapport/pdf/route.ts",
"app/api/reports/balansrapport/route.ts",
"app/api/reports/balansrapport/xlsx/route.ts",
"app/api/reports/continuity-check/route.ts",
"app/api/reports/full-archive/route.ts",
"app/api/reports/general-ledger/xlsx/route.ts",
"app/api/reports/income-statement/pdf/route.ts",
"app/api/reports/income-statement/xlsx/route.ts",
"app/api/reports/journal-register/route.ts",
"app/api/reports/journal-register/xlsx/route.ts",
"app/api/reports/kassaflodesanalys/pdf/route.ts",
"app/api/reports/kassaflodesanalys/route.ts",
"app/api/reports/kpi/route.ts",
"app/api/reports/kpi/xlsx/route.ts",
"app/api/reports/monthly-breakdown/route.ts",
"app/api/reports/monthly-breakdown/xlsx/route.ts",
"app/api/reports/resultatrapport/pdf/route.ts",
"app/api/reports/resultatrapport/route.ts",
"app/api/reports/resultatrapport/xlsx/route.ts",
"app/api/reports/salary-journal/route.ts",
"app/api/reports/salary-journal/xlsx/route.ts",
"app/api/reports/supplier-ledger/route.ts",
"app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts",
"app/api/reports/supplier-ledger/xlsx/route.ts",
"app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts",
"app/api/reports/trial-balance/route.ts",
"app/api/reports/trial-balance/xlsx/route.ts",
"app/api/reports/vacation-liability/route.ts",
"app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts",
"app/api/reports/vat-declaration/xlsx/route.ts",
"app/api/salary/employees/[id]/absence/route.ts",
"app/api/salary/employees/[id]/benefits/[benefitId]/route.ts",
"app/api/salary/employees/[id]/benefits/route.ts",
"app/api/salary/employees/[id]/route.ts",
"app/api/salary/employees/[id]/worked-hours/batch/route.ts",
"app/api/salary/employees/[id]/worked-hours/route.ts",
"app/api/salary/employees/route.ts",
"app/api/salary/ku/[year]/route.ts",
"app/api/salary/payroll-config/[year]/route.ts",
"app/api/salary/runs/[id]/agi/submit/route.ts",
"app/api/salary/runs/[id]/agi/xml/route.ts",
"app/api/salary/runs/[id]/correct/route.ts",
"app/api/salary/runs/[id]/employees/[employeeId]/route.ts",
"app/api/salary/runs/[id]/employees/route.ts",
"app/api/salary/runs/[id]/lines/[lineId]/route.ts",
"app/api/salary/runs/[id]/lines/route.ts",
"app/api/salary/runs/[id]/payment/bg-lb/route.ts",
"app/api/salary/runs/[id]/payment/pain001/route.ts",
"app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts",
"app/api/salary/runs/[id]/payslips/send/route.ts",
"app/api/salary/runs/[id]/preview/route.ts",
"app/api/salary/runs/[id]/review/route.ts",
"app/api/salary/runs/[id]/route.ts",
"app/api/salary/tax-tables/lookup/route.ts",
"app/api/salary/tax-tables/status/route.ts",
"app/api/settings/api-keys/[id]/route.ts",
"app/api/settings/booking-templates/[id]/route.ts",
"app/api/settings/booking-templates/[id]/touch/route.ts",
"app/api/settings/booking-templates/export/route.ts",
"app/api/settings/booking-templates/import/route.ts",
"app/api/settings/booking-templates/route.ts",
"app/api/settings/counterparty-templates/route.ts",
"app/api/settings/logo/route.ts",
"app/api/settings/oauth-clients/[id]/route.ts",
"app/api/settings/oauth-clients/route.ts",
"app/api/settings/route.ts",
"app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts",
"app/api/skatteverket/tax-payments/[period]/payment-file/route.ts",
"app/api/skatteverket/tax-payments/[period]/route.ts",
"app/api/supplier-invoices/[id]/route.ts",
"app/api/supplier-invoices/[id]/uncredit/route.ts",
"app/api/support/contact/route.ts",
"app/api/tax-deadlines/generate/route.ts",
"app/api/team/accept/route.ts",
"app/api/team/members/route.ts",
"app/api/transactions/[id]/attach-document/route.ts",
"app/api/transactions/[id]/book/route.ts",
"app/api/transactions/[id]/ignore/route.ts",
"app/api/transactions/[id]/uncategorize/route.ts",
"app/api/transactions/batch-match-invoices/route.ts",
"app/api/transactions/create-from-document/route.ts",
"app/api/transactions/route.ts",
"app/api/transactions/suggest-categories/route.ts",
"app/api/vat/validate/route.ts"
]
},
"naiveOreRound": {
"count": 668
}
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Ratchet guard against post-audit antipatterns.
*
* The audit found two repository-wide problems that are being remediated in
* dedicated campaigns (A1 = route auth/MFA, D1 = money rounding). Those touch
* hundreds of sites and won't land in one PR — so this guard makes sure the
* count can only go DOWN, never up, while the migrations are in flight.
*
* Checks:
* 1. raw-route-auth — an `app/api/**\/route.ts` that calls
* `supabase.auth.getUser()` directly instead of going through
* `requireAuth()` / `withRouteContext()` (the only guards that enforce
* MFA AAL2 on hosted). Tracked as a file-set so a NEW offending route
* fails CI even if an old one was fixed in the same PR.
* 2. naive-ore-round — `Math.round(x * 100) / 100`, which is subtly wrong on
* exact-half values (see lib/money.ts `roundOre`). Tracked as a count.
* The canonical rounding modules are excluded.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
* node scripts/checks/no-new-antipatterns.mjs --update # re-baseline after a migration ratchets the count down
*
* Exit code 1 if either check regressed past its baseline.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'antipatterns-baseline.json')
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage'])
// The sanctioned home of the öre-round implementation — must not count against itself.
const ROUND_EXEMPT = new Set(['lib/money.ts', 'lib/bokslut/rounding.ts'])
const RAW_AUTH_RE = /\.auth\.getUser\(/
// Match the guard at its CALL site, not a bare import, so a file that imports
// withRouteContext but still hand-rolls getUser() on another handler is still
// flagged. withRouteContext is usually called with a generic (`withRouteContext<…>(`),
// so accept either `<` or `(` after the name.
const GUARD_RE = /requireAuth\(|withRouteContext[<(]/
const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/
function walk(dir, exts, out = []) {
let entries
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const e of entries) {
if (e.name.startsWith('.') && e.name !== '.well-known') continue
const full = path.join(dir, e.name)
if (e.isDirectory()) {
if (!IGNORE_DIRS.has(e.name)) walk(full, exts, out)
} else if (exts.some((x) => e.name.endsWith(x))) {
out.push(full)
}
}
return out
}
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/')
/** Route files that hand-roll auth instead of the MFA-enforcing guard. */
function findRawRouteAuth() {
const apiDir = path.join(ROOT, 'app', 'api')
return walk(apiDir, ['route.ts'])
.filter((f) => {
const src = fs.readFileSync(f, 'utf8')
return RAW_AUTH_RE.test(src) && !GUARD_RE.test(src)
})
.map(rel)
.sort()
}
/** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */
function countNaiveRound() {
const files = [
...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
]
let count = 0
for (const f of files) {
if (ROUND_EXEMPT.has(rel(f))) continue
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
if (NAIVE_ROUND_RE.test(line)) count++
}
}
return count
}
const current = {
rawRouteAuth: findRawRouteAuth(),
naiveOreRound: countNaiveRound(),
}
const isUpdate = process.argv.includes('--update')
if (isUpdate) {
const baseline = {
_comment:
'Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).',
rawRouteAuth: { count: current.rawRouteAuth.length, files: current.rawRouteAuth },
naiveOreRound: { count: current.naiveOreRound },
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
`Baseline written: ${current.rawRouteAuth.length} raw-route-auth files, ${current.naiveOreRound} naive-ore-round occurrences.`,
)
process.exit(0)
}
if (!fs.existsSync(BASELINE_PATH)) {
console.error('No baseline found. Run: node scripts/checks/no-new-antipatterns.mjs --update')
process.exit(1)
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
let failed = false
// 1. raw-route-auth: any file not in the baseline set is a NEW violation.
const baselineSet = new Set(baseline.rawRouteAuth.files)
const newAuthFiles = current.rawRouteAuth.filter((f) => !baselineSet.has(f))
const fixedAuthFiles = baseline.rawRouteAuth.files.filter((f) => !current.rawRouteAuth.includes(f))
if (newAuthFiles.length) {
failed = true
console.error(
`\n✗ raw-route-auth: ${newAuthFiles.length} new route(s) call supabase.auth.getUser() directly ` +
`instead of requireAuth()/withRouteContext() (skips MFA AAL2 enforcement):`,
)
newAuthFiles.forEach((f) => console.error(` ${f}`))
console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.')
}
// 2. naive-ore-round: count may not increase.
if (current.naiveOreRound > baseline.naiveOreRound.count) {
failed = true
console.error(
`\n✗ naive-ore-round: ${current.naiveOreRound} occurrences of Math.round(x*100)/100 ` +
`(baseline ${baseline.naiveOreRound.count}, +${current.naiveOreRound - baseline.naiveOreRound.count}).`,
)
console.error(' → import roundOre from @/lib/money instead.')
}
// Report ratchet-down progress (informational, never fails).
if (fixedAuthFiles.length || current.naiveOreRound < baseline.naiveOreRound.count) {
console.log('\n✓ Progress since baseline:')
if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`)
if (current.naiveOreRound < baseline.naiveOreRound.count)
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
}
if (failed) {
console.error('\nAntipattern guard failed — see above.')
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}).`,
)