fix(auth): route document integrity, transaction delete/list and agent categorize through withRouteContext (#1926)

Two handlers hand-rolled supabase.auth.getUser() and therefore skipped the
MFA (AAL2) gate on hosted: DELETE /api/transactions/[id] and
GET /api/transactions. Both sat next to a sibling handler that was already
wrapped, and the raw-route-auth ratchet exempted a file as soon as any
withRouteContext call appeared in it, so they were never flagged.

GET /api/documents/[id]/integrity and POST /api/agent/categorize called
requireAuth() directly (MFA enforced, but no request id, no completion log,
no canonical error envelope). All four are now withRouteContext handlers
with identical company scoping and responses; the transaction delete keeps
its viewer rejection via requireWrite.

The guard now judges each top-level export segment of a route file on its
own, so a wrapped handler no longer exempts a hand-rolled sibling. Baseline
is unchanged (mcp-oauth/authorize remains the one grandfathered file).

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 13:34:31 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent d3869e6694
commit 1a27b5bd4a
10 changed files with 393 additions and 311 deletions
+17 -6
View File
@@ -11,8 +11,11 @@
* 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.
* MFA AAL2 on hosted). Judged per exported handler, not per file: a
* wrapped PATCH next to a hand-rolled DELETE in the same file is still
* a violation (that exact shape hid two MFA bypasses until 2026-08-26).
* 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.
@@ -136,6 +139,10 @@ const RAW_AUTH_RE = /\.auth\.getUser\(/
// flagged. withRouteContext is usually called with a generic (`withRouteContext<…>(`),
// so accept either `<` or `(` after the name.
const GUARD_RE = /requireAuth\(|withRouteContext[<(]/
// Each top-level `export` starts a new segment, so every handler (and the
// preamble of shared helpers above the first export) is judged on its own.
// Without this split, one wrapped handler exempted the whole file.
const TOP_LEVEL_EXPORT_RE = /^(?=export\s)/m
const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/
// 8. hand-rolled-invariant. Shared format contracts live in lib/invariants/
@@ -178,14 +185,18 @@ function walk(dir, exts, out = []) {
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/')
/** True when any handler segment calls getUser() without an MFA-enforcing guard. */
function handRollsRouteAuth(src) {
return src
.split(TOP_LEVEL_EXPORT_RE)
.some((segment) => RAW_AUTH_RE.test(segment) && !GUARD_RE.test(segment))
}
/** 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)
})
.filter((f) => handRollsRouteAuth(fs.readFileSync(f, 'utf8')))
.map(rel)
.sort()
}