Problem
The codebase has two different patterns for Bearer-token-protected API routes:
- The shared helper
requireBearerSecret(request, ENV_NAME) (and its cron-specific wrapper requireCronSecret) used by every /api/sync/* route.
- A custom local
validateAuth(request) in /api/invoices/ingest/route.ts that re-implements the same pattern slightly differently.
Two implementations of the same primitive means a future hardening (e.g. constant-time comparison, audit logging on auth failure, rate limiting) has to be applied twice and can drift. It's also harder for a new contributor — or an AI agent — to discover the canonical way to add a new protected route.
Evidence
src/app/api/invoices/ingest/route.ts:21-26:
function validateAuth(request: NextRequest): boolean {
const secret = process.env.INVOICE_INGEST_SECRET;
if (!secret) return false;
const authHeader = request.headers.get(\"authorization\");
return authHeader === `Bearer ${secret}`;
}
vs the shared helper used by /api/sync/* routes (see src/lib/cron-auth.ts or wherever requireBearerSecret lives).
The local one:
- Does plain string equality (timing-attack-vulnerable; the shared helper should use
timingSafeEqual if it doesn't already).
- Returns a
boolean instead of returning a NextResponse | null like the shared helper, so the calling code has to write its own 401 response.
Proposed approach
- Read
src/lib/cron-auth.ts (or the canonical location of requireBearerSecret) to confirm the contract.
- If the shared helper does not already use a constant-time comparison, fix that first as part of this issue (use
crypto.timingSafeEqual after coercing to Buffer of equal length).
- Replace
validateAuth in /api/invoices/ingest/route.ts with a call to the shared helper passing \"INVOICE_INGEST_SECRET\".
- Sweep the rest of
src/app/api/**/route.ts for any other one-off Bearer checks and replace them.
- Add a unit test for
requireBearerSecret:
- missing header → returns 401 response
- wrong secret → returns 401
- correct secret → returns null (i.e. "continue")
- missing env var → returns 500 / clear error (or whatever the helper currently does — match it)
Acceptance criteria
Verification
- POST
/api/invoices/ingest with no Authorization header → 401.
- POST with wrong Bearer → 401.
- POST with correct Bearer + valid PDF → 200 (or expected response).
- Existing
/api/sync/* routes still work unchanged.
Problem
The codebase has two different patterns for Bearer-token-protected API routes:
requireBearerSecret(request, ENV_NAME)(and its cron-specific wrapperrequireCronSecret) used by every/api/sync/*route.validateAuth(request)in/api/invoices/ingest/route.tsthat re-implements the same pattern slightly differently.Two implementations of the same primitive means a future hardening (e.g. constant-time comparison, audit logging on auth failure, rate limiting) has to be applied twice and can drift. It's also harder for a new contributor — or an AI agent — to discover the canonical way to add a new protected route.
Evidence
src/app/api/invoices/ingest/route.ts:21-26:vs the shared helper used by
/api/sync/*routes (seesrc/lib/cron-auth.tsor whereverrequireBearerSecretlives).The local one:
timingSafeEqualif it doesn't already).booleaninstead of returning aNextResponse | nulllike the shared helper, so the calling code has to write its own 401 response.Proposed approach
src/lib/cron-auth.ts(or the canonical location ofrequireBearerSecret) to confirm the contract.crypto.timingSafeEqualafter coercing toBufferof equal length).validateAuthin/api/invoices/ingest/route.tswith a call to the shared helper passing\"INVOICE_INGEST_SECRET\".src/app/api/**/route.tsfor any other one-off Bearer checks and replace them.requireBearerSecret:Acceptance criteria
src/app/api/.requireBearerSecretuses constant-time comparison./api/invoices/ingestis unchanged (auth still works, error responses still match).pnpm lint && pnpm typecheck && pnpm testpass.Verification
/api/invoices/ingestwith noAuthorizationheader → 401./api/sync/*routes still work unchanged.