Files
accounted/lib/api/with-route-context.ts
T
Jakob Wennberg 0b86901a2b 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>
2026-06-03 15:34:58 +02:00

172 lines
6.7 KiB
TypeScript

/**
* Single wrapper that gives every API route the same shape:
*
* - generates a request id (`req_<uuid>`) and threads it through the logger
* - resolves auth via requireAuth() and (by default) the active companyId
* - emits one structured `info` log on completion with duration
* - converts any thrown value into the canonical error envelope via
* errorResponse(); the request id appears in the response body and the
* X-Request-Id response header. Unhandled errors are logged ("op failed")
* with the resolved { requestId, operation, userId, companyId } context.
*
* Usage:
* export const POST = withRouteContext('invoice.send', async (req, ctx) => {
* // ctx.requestId, ctx.log, ctx.user, ctx.supabase, ctx.companyId
* const result = await sendInvoice(...)
* return NextResponse.json({ data: result })
* })
*
* For dynamic routes the second parameter is the Next.js params promise:
* export const POST = withRouteContext('invoice.send', async (req, ctx, { params }) => {
* const { id } = await params
* ...
* })
*/
import type { SupabaseClient, User } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireWritePermission } from '@/lib/auth/require-write'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger, type Logger } from '@/lib/logger'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
export interface RouteContext {
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id header. */
requestId: string
/** Logger pre-bound with { requestId, userId, companyId, operation }. */
log: Logger
/** Authenticated user. Always present — wrapper short-circuits with 401 otherwise. */
user: User
/** Authenticated Supabase client (request-scoped, RLS active). */
supabase: SupabaseClient
/**
* Resolved active company id. The wrapper short-circuits with
* COMPANY_CONTEXT_MISSING before invoking the handler when no company is
* resolved, so handlers can treat this as guaranteed non-null. Routes that
* need to opt out of the guarantee (e.g. onboarding) shouldn't use
* withRouteContext.
*
* Membership invariant: `getActiveCompanyId` only returns a company the
* authenticated user is a current member of (it validates
* `company_members` and excludes archived companies). The handler may
* therefore treat `companyId` as "a company the caller is authorized to
* read", and routes that mutate state additionally enforce a non-viewer
* role via `requireWrite: true`. ASVS V8.2.1 / SOC 2 CC6.3.
*/
companyId: string
}
interface RouteContextOptions {
/**
* Defaults to false. When true, the wrapper rejects callers whose role in
* the active company is `viewer` (or who have no membership). Mirrors the
* existing requireWritePermission() helper so mutating routes can drop two
* lines of boilerplate.
*/
requireWrite?: boolean
}
// Next.js 16 always passes a `{ params: Promise<...> }` second arg to route
// handlers — including on non-dynamic routes, where it's `Promise<{}>`. The
// generic defaults to that empty shape so static routes type-check without
// having to declare any params at the call site.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type DynamicParams = { params: Promise<Record<string, string | string[]>> } | { params: Promise<{}> }
type RouteHandler<P extends DynamicParams = { params: Promise<Record<string, never>> }> = (
request: Request,
ctx: RouteContext,
params: P,
) => Promise<NextResponse | Response>
function generateRequestId(): string {
// crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js.
return `req_${crypto.randomUUID()}`
}
export function withRouteContext<P extends DynamicParams = { params: Promise<Record<string, never>> }>(
operation: string,
handler: RouteHandler<P>,
options: RouteContextOptions = {},
): (request: Request, params: P) => Promise<Response> {
const { requireWrite = false } = options
return async function wrapped(request: Request, params: P): Promise<Response> {
const requestId = generateRequestId()
const start = Date.now()
const log = createLogger(`api/${operation}`, { requestId, operation })
// Upgraded as request context resolves, so an unhandled throw in the catch
// below is logged with the richest available { userId, companyId } context
// (audit trail / OWASP V16), not just { requestId, operation }.
let errLog = log
try {
const auth = await requireAuth()
if (auth.error) {
log.warn('auth failed', { status: auth.error.status })
// Pass through requireAuth's response unchanged for backwards-compat
// with existing route tests; only inject the request id header so
// support can still trace the request.
if (!auth.error.headers.get('X-Request-Id')) {
auth.error.headers.set('X-Request-Id', requestId)
}
return auth.error
}
const { user, supabase } = auth
const userLog = log.child({ userId: user.id })
errLog = userLog
let companyId: string | null = null
try {
companyId = await getActiveCompanyId(supabase, user.id)
} catch (err) {
userLog.error('failed to resolve active company', err as Error)
}
if (!companyId) {
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
}
if (requireWrite) {
// Delegate to the existing helper so tests that already mock it
// continue to work. The helper returns its own 403 NextResponse;
// we wrap it in our request-id header for traceability.
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) {
userLog.warn('write permission denied')
if (!writeCheck.response.headers.get('X-Request-Id')) {
writeCheck.response.headers.set('X-Request-Id', requestId)
}
return writeCheck.response
}
}
const ctx: RouteContext = {
requestId,
log: userLog.child({ companyId }),
user,
supabase,
companyId,
}
errLog = ctx.log
const response = await handler(request, ctx, params)
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
response.headers.set('X-Request-Id', requestId)
}
ctx.log.info('op completed', {
durationMs: Date.now() - start,
status: response.status,
})
return response
} catch (err) {
errLog.error('op failed', err as Error, { durationMs: Date.now() - start })
return errorResponse(err, errLog, { requestId })
}
}
}