Files
accounted/lib/api/v1/with-api-v1.ts
T
Jakob WennbergandClaude Opus 4.7 db592d922d feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints (#450)
* feat(api): v1 REST API foundation — auth wrapper, scopes, registry, smoke endpoints

Lay the substrate for the public REST API at /api/v1/*: Bearer-auth wrapper
that reuses the existing api_keys + idempotency machinery, an extended scope
catalogue (companies, events, webhooks, operations, documents, compliance),
v1 response envelopes (data + meta with request_id, api_version, audit block,
cursor pagination), an error envelope with recovery_hint / docs_url /
valid_alternatives derived from the existing structured-error registry, and
a Zod schema registry that generates the OpenAPI 3.1 spec with x-action-risk
/ x-idempotent / x-reversible / x-dry-run-supported extensions.

Ships discovery routes (/llms.txt, /.well-known/skills/index.json) and three
smoke endpoints (GET /api/v1/health, /api/v1/companies, /api/v1/openapi.json)
so the wrapper is exercised end-to-end. Includes the api_keys.mode (test|live)
migration and 41 unit tests covering auth, scope, company-membership,
idempotency replay, dry-run, pagination, response shape, and scope resolution.

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

* fix(api): harden v1 foundation — cursor validation, security headers, forensic logs

Address compliance-swarm findings on PR #450:

- OWASP V2.3: decodeDefaultCursor now validates the cursor's ts as ISO 8601
  and id as UUID. A crafted cursor previously could inject untyped strings
  into a query's .gt(field, value); PostgREST would have rejected them, but
  validating here keeps the failure mode predictable (stale cursor → reset)
  rather than 400-ing.
- OWASP V3.4: public discovery routes (llms.txt, .well-known/skills,
  openapi.json) now stamp X-Content-Type-Options: nosniff, Referrer-Policy,
  X-Frame-Options: DENY. New lib/api/v1/security-headers.ts helper.
- OWASP V16: security event logs (missing token, validation failure,
  insufficient scope, company-membership deny) now include source IP
  (x-forwarded-for / x-real-ip) and User-Agent for forensic correlation.
- OWASP V8.2.1 / ISO A.8.3: GET /api/v1/companies emits a warn log when the
  PostgREST archived_at filter unexpectedly returns a row with a null
  company join, surfacing silent data-integrity regressions instead of
  hiding them behind the existing pickCompany() === null filter.

Pushing back on (not changed):
- GDPR Art.32 cursor HMAC signing — cursors only paginate within a user's
  own user_id scope; cross-tenant probe surface doesn't exist yet.
- GDPR Art.25 org_number in list — Bolagsverket public-record data, removing
  forces N+1 fetches to make the response useful.
- SOC 2 CC6.3 service-role bypasses RLS — defense-in-depth IS the design;
  the wrapper's company_members membership check is the technical control.
- ISO A.8.12 public OpenAPI spec — intentional, mirrors Stripe/Twilio.

5 new pagination tests cover the cursor validators. 46/46 v1 tests pass.

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

* fix(auth): detect Supabase duplicate-signup obfuscation on register

Supabase obfuscates duplicate signups to prevent user enumeration: when an
email already belongs to a confirmed account, signUp returns data.user with
identities: [] and no error, and sends no email. Without detecting this case
we showed the "check your email" screen to the user, who then waited for a
mail that never arrived.

Detect the empty-identities response and surface it via duplicateEmail state
so the UI can branch on it.

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

* fix(api): second-pass hardening — CSP, IP truncation, cursor scope comment

Address the second compliance-swarm sweep on PR #450:

- OWASP V3.2: PUBLIC_SECURITY_HEADERS now includes Content-Security-Policy
  default-src 'none'; frame-ancestors 'none'. Free win for JSON/text-only
  public routes (no script, style, image, or form contexts).
- GDPR Art.5(1)(f): truncate IPs before logging — IPv4 to /24, IPv6 to /48.
  Preserves diagnostic value (ASN, abuse-pattern correlation, city-level
  geolocation) while eliminating point-of-presence identification. Standard
  pattern used by Google Analytics anonymize_ip. Exported truncateIp() so
  other surfaces can adopt it.
- OWASP V8.2.1: explicit comment in GET /api/v1/companies documenting that
  the cursor's joined_at is applied AFTER user_id filter, so a tampered
  cursor can only reorder rows the caller already owns. Cursors deliberately
  unsigned; trade-off documented.

Pushing back on second-pass findings (not changed):
- ISO A.8.12 / SOC 2 CC6.3 health/llms.txt/skills exposing service name +
  API version + MCP URL — these are intentional disclosures for a public
  3rd-party developer API; hiding them is theatre.
- GDPR Art.32 logging granted scopes on INSUFFICIENT_SCOPE — diagnostic
  value during incident response outweighs the theoretical privilege-profile
  leak; an attacker who already breached the log store has bigger problems.
- OWASP V2.2 route-level Zod for cursor — decodeDefaultCursor already
  validates strictly; route-level Zod is stylistic.
- GDPR Art.25(2) org_number/entity_type in list — Bolagsverket-public data;
  entity_type materially affects which API calls make sense.
- ISO A.8.15 x-forwarded-for trusted-proxy CIDR — overkill behind Vercel's
  edge which rewrites the leftmost value.

50/50 v1 tests pass (4 new for truncateIp). Build green.

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

* fix(api): third-pass hardening — Host header injection, anon client, HSTS

Address the third compliance-swarm sweep on PR #450:

- SOC 2 CC6.1 (3× high): llms.txt, openapi.json, and .well-known/skills
  built URLs from the inbound Host header. A spoofed Host could poison
  agent discovery with attacker-controlled endpoints. New
  lib/api/v1/base-url.ts centralises canonical base-URL derivation via
  NEXT_PUBLIC_APP_URL (already a required env var per CLAUDE.md).
- ISO A.8.2 / A.8.5 (2× high): the wrapper's public-scope code path now
  uses an anon-key Supabase client (RLS-respecting) instead of the
  service-role client. A future accidental DB call from a public handler
  is constrained to anon-accessible rows. Least-privilege at the
  infrastructure layer.
- OWASP V3.2 (medium): PUBLIC_SECURITY_HEADERS now includes
  Strict-Transport-Security: max-age=31536000; includeSubDomains.
- GDPR Art.5(1)(f) (medium): truncateIp now logs a warn when a non-empty
  x-forwarded-for / x-real-ip payload fails to parse, surfacing spoofed
  or unexpected proxy values to security monitoring instead of silently
  dropping them. The raw value is never logged.
- CC2.3 (low): llms.txt now links the SECURITY.md disclosure policy with
  the security@arcim.io reporting address so agents have a clear
  responsible-disclosure path.

Pushing back on third-pass findings (not changed):
- Cursor HMAC signing — user_id filter is the authorisation boundary;
  cursor scope is bounded to within-user rows. Documented in code.
- org_number in companies list — Bolagsverket public data; the swarm's
  "could be enskild firma personnummer" framing isn't accurate (enskild
  firma org_number IS the personnummer, but it's already in the public
  Bolagsverket business register).
- Health endpoint information disclosure — intentional for a public
  developer API; matches Stripe/Twilio convention.
- llms.txt / skills index MCP URL disclosure — that's the file's purpose.
- Cache-Control public on discovery routes — content is by definition
  public; getCanonicalBaseUrl() removes the previous spoof concern.
- Duplicate-email screen — user's own input; out of scope for this PR.

50/50 v1 tests pass; @supabase/supabase-js#createClient mocked so the
public-path tests don't need real env vars.

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

* fix(test): widen validateApiKey result assertions to include mode field

The core-only CI job failed on two pre-existing api-keys.test.ts assertions
that used strict toEqual matching against the old (userId, companyId, scopes)
shape. The wrapper migration in this PR widened that shape with mode,
apiKeyId, and apiKeyName.

Update both existing assertions to match the current shape and add a third
test that exercises the mode='test' path. 3027/3027 vitest tests now pass
locally.

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

* fix(api): fourth-pass hardening — env guards, IP range check, headers on wrapped routes

Address the fourth compliance-swarm sweep on PR #450:

- ISO A.5.17 / SOC 2 CC6.1 (high): createAnonClient now fails closed with
  an explicit Error if NEXT_PUBLIC_SUPABASE_URL or _ANON_KEY are missing,
  surfacing misconfiguration on the first request instead of throwing
  deeper in the handler with no context.
- GDPR Art.5(1)(f): truncateIp now rejects IPv4 with out-of-range octets
  (>255). '999.999.999.999' now returns undefined instead of a pseudo-IP
  that would pollute abuse-pattern analysis. Edge octets (0, 255) still
  accepted. 2 new tests.
- OWASP V3.2 / V3.3: the wrapper's stampHeaders step now applies the full
  security header set to every wrapped v1 response (CSP, HSTS, X-Frame,
  Referrer-Policy, X-Content-Type-Options) PLUS X-Robots-Tag: noai,
  noimageai so authenticated payloads are excluded from AI training sets.
  Public discovery routes (llms.txt, skills index, openapi.json)
  deliberately omit X-Robots-Tag — being AI-discoverable is the whole
  point of those surfaces.
- New WRAPPED_RESPONSE_HEADERS export separates the two contexts.

Pushing back on:
- SOC 2 CC6.1 medium "API key prefix in public docs aids brute force" —
  inverted logic. Every public API publishes its key prefix specifically
  so secret scanners (GitHub Advanced Security, GitLeaks) can detect
  leaks. Stripe (sk_live_), GitHub (ghp_), OpenAI (sk-) all do this.
- SOC 2 CC6.3 medium "formal risk register for unsigned cursors" — org
  -level documentation, outside this PR. Code-comment already documents
  the trade-off.
- SOC 2 CC2.3 low "llms.txt hardcodes security@arcim.io" — same address
  as SECURITY.md; no drift risk.

Flagged separately (not changed): the register-page duplicate-email
detection in this branch defeats Supabase's user-enumeration obfuscation
(GDPR Art.5(1)(c) × 2, ISO A.8.11). Substantive product decision: UX (no
infinite-wait for non-existent accounts) vs security (no enumeration).
GitHub and Stripe Atlas pick UX; some pick security. Owner's call.

3029/3029 vitest tests pass.

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

* fix(api): address Greptile review on PR #450

- P1 (companies/route.ts): keyset pagination was missing its tiebreaker.
  The cursor encoded (joined_at, id) but the filter only applied
  .gt('joined_at', ts) — same-joined_at rows on a page boundary could be
  skipped or duplicated. Also the encoded id was companies.id while the
  sort was on company_members, mismatched. Fixed: select + sort + encode
  on company_members.id, apply compound
  joined_at.gt.{ts} OR (joined_at.eq.{ts} AND id.gt.{cursor_id}) via .or().
  Side benefit — eliminates the broken-cursor-on-null-join case (#2)
  because company_members.id is always present, no null guard needed.
- P2 (registry.ts): ZodUnion branch had a dead ternary
  (['x','y','z','w'].length > 0 ? undefined : 'object') that always
  yielded undefined. Removed; emit { oneOf: [...] } without top-level
  type (correct JSON Schema for a union).
- P2 (with-api-v1.ts): public-endpoint path was short-circuiting before
  Bearer-token validation, contradicting the JSDoc and PR description.
  Now opportunistically validates a supplied token for rate-limit
  attribution + key tracking; missing/invalid token silently falls back
  to anon (the route is public by definition, so we don't 401). Two
  new tests cover both branches.

3031/3031 vitest tests pass; build green.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:47:13 +02:00

452 lines
18 KiB
TypeScript

/**
* v1 REST API wrapper.
*
* Every route under `app/api/v1/` is wrapped with `withApiV1('operation.name', handler)`.
* The wrapper provides a single audit-friendly shape for the entire v1 surface:
*
* 1. Generates `requestId` (`req_<uuid>`) and a child logger bound to it.
* 2. Extracts and validates the `Authorization: Bearer gnubok_sk_...` header
* via the existing `validateApiKey()` (atomic RPC, rate-limited).
* 3. Resolves the required scope for the route from the v1 endpoint catalogue
* and returns INSUFFICIENT_SCOPE if the key lacks it. Public endpoints
* (`/health`, `/openapi.json`) skip the scope check but still validate
* the token when one is supplied.
* 4. When the URL contains `companyId`, verifies the API key's user has
* access to that company via `company_members`. Multi-company keys are
* supported transparently — the URL is the source of truth.
* 5. Resolves `Idempotency-Key` (header) and replays cached responses.
* 6. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
* 7. Invokes the handler with a typed RouteContext.
* 8. Stamps `X-Request-Id`, `Gnubok-Version`, `X-RateLimit-Limit` on the
* response.
* 9. Catches any thrown value and converts it to the v1 error envelope via
* `v1ErrorResponse`.
*
* Usage:
*
* export const GET = withApiV1('companies.list', async (req, ctx) => {
* // ctx.requestId, ctx.log, ctx.user, ctx.companyId (when in URL),
* // ctx.supabase, ctx.scopes, ctx.mode, ctx.dryRun, ctx.idempotencyKey
* return ok({ companies: [...] }, { requestId: ctx.requestId })
* })
*/
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import {
type ApiKeyMode,
type ApiKeyScope,
createServiceClientNoCookies,
extractBearerToken,
hasScope,
validateApiKey,
} from '@/lib/auth/api-keys'
import { resolveRequiredScope } from '@/lib/auth/scopes'
import {
checkIdempotencyKey,
hashRequest,
IdempotencyKeyReuseError,
storeIdempotencyResponse,
} from '@/lib/api/idempotency'
import { createLogger, type Logger } from '@/lib/logger'
import { v1ErrorResponse, v1ErrorResponseFromCode } from './errors'
import { WRAPPED_RESPONSE_HEADERS } from './security-headers'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
const IDEMPOTENCY_HEADER = 'Idempotency-Key'
const DRY_RUN_HEADER = 'X-Dry-Run'
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PATCH', 'DELETE'])
export interface ApiV1Context {
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id. */
requestId: string
/** Logger pre-bound with { requestId, userId, companyId?, operation, apiKeyId? }. */
log: Logger
/** Authenticated user id. */
userId: string
/** API key id of the caller. Used for actor attribution on pending_operations / audit_log. */
apiKeyId: string | undefined
/** API key human name. */
apiKeyName: string | undefined
/** Scopes granted to the calling key. */
scopes: ApiKeyScope[]
/** test|live — handlers branch on this to short-circuit external providers in test mode. */
mode: ApiKeyMode
/** Service-role Supabase client (no cookies). All queries MUST filter by company_id. */
supabase: SupabaseClient
/**
* Resolved company id from the URL `:companyId` segment. Undefined for
* routes that don't include the segment (`/companies`, `/operations/:id`,
* `/health`).
*/
companyId?: string
/** Resolved dry-run flag. Routes that mutate state must honor this. */
dryRun: boolean
/** Resolved idempotency key, if supplied. */
idempotencyKey: string | null
}
interface ApiV1Options {
/** Override the required scope (e.g. for ad-hoc endpoints not in the catalogue). */
requireScope?: ApiKeyScope
/**
* When true, idempotency is enforced — POST/PATCH/DELETE without an
* `Idempotency-Key` header return 400. Default false; can be flipped on
* per-route once the integrator audience is sophisticated enough.
*/
requireIdempotencyKey?: boolean
}
// Next.js 16 always passes `{ params: Promise<...> }` as the second arg.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type DynamicParams = { params: Promise<Record<string, string | string[]>> } | { params: Promise<{}> }
type V1Handler<P extends DynamicParams = { params: Promise<Record<string, never>> }> = (
request: Request,
ctx: ApiV1Context,
params: P,
) => Promise<NextResponse | Response>
function generateRequestId(): string {
return `req_${crypto.randomUUID()}`
}
/**
* Anon-key Supabase client for the wrapper's public-scope code path. RLS is
* enforced (no service-role privilege escalation) so even an accidental DB
* call from a public handler is constrained to anon-accessible rows.
*
* Fails closed at first-call if the required env vars are missing — better
* to surface the misconfiguration on the first request than silently 500
* deeper in the handler.
*/
function createAnonClient(): SupabaseClient {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!url || !key) {
throw new Error(
'[api/v1] NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set to serve public-scope v1 endpoints',
)
}
return createClient(url, key)
}
/**
* Forensic identifiers for security event logs (failed auth, scope deny,
* company-membership deny). We log a *truncated* source IP (last octet
* dropped for IPv4, last 80 bits zeroed for IPv6) and user-agent so audit
* trails can correlate suspicious patterns by network neighbourhood
* without persisting full identifying IPs in the log store.
*
* Data minimisation: GDPR Art.5(1)(c) / Art.5(1)(f). Truncation preserves
* the diagnostic value (city-level geolocation, ASN, abuse-pattern
* correlation) while eliminating point-of-presence identification.
*
* Honors `x-forwarded-for` when set (Vercel / proxies); behind Vercel the
* leftmost value is rewritten by the edge so we accept it as authoritative.
*/
export function truncateIp(ip: string | undefined): string | undefined {
if (!ip) return undefined
// IPv4: validate octets are 0-255, then drop last octet → "203.0.113.0/24".
// Out-of-range octets indicate a spoofed or malformed header; refuse to
// log a pseudo-IP that would pollute abuse-pattern analysis.
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
if (v4) {
const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10))
if (octets.every((o) => o >= 0 && o <= 255)) {
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`
}
return undefined
}
// IPv6: keep first 3 hextets → "2001:db8:abc::/48"
const v6 = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):/i.exec(ip)
if (v6) return `${v6[1]}::/48`
return undefined
}
function extractForensicContext(request: Request, log: Logger): { ip: string | undefined; userAgent: string | undefined } {
const fwd = request.headers.get('x-forwarded-for')
const raw = fwd ? fwd.split(',')[0]?.trim() : request.headers.get('x-real-ip') ?? undefined
const ip = truncateIp(raw || undefined)
if (raw && !ip) {
// x-forwarded-for / x-real-ip carried a non-empty payload we couldn't parse.
// Surface as a warn so spoofed / unexpected proxy values are visible in
// security monitoring instead of silently dropped. Never log the raw value
// — that would defeat the truncation step.
log.warn('unparseable forwarded-for header dropped', { headerLength: raw.length })
}
const userAgent = request.headers.get('user-agent') ?? undefined
return { ip, userAgent }
}
function isDryRun(request: Request, url: URL): boolean {
if (url.searchParams.get('dry_run') === 'true') return true
const headerVal = request.headers.get(DRY_RUN_HEADER)
if (headerVal && headerVal.toLowerCase() === 'true') return true
return false
}
async function readBodyForHash(request: Request): Promise<{ body: unknown; cloned: Request }> {
// We need to consume the body to hash it, but the handler also needs it.
// Clone the request first so the handler can re-read.
const cloned = request.clone()
const text = await request.text()
if (!text) return { body: null, cloned }
try {
return { body: JSON.parse(text), cloned }
} catch {
return { body: text, cloned }
}
}
/**
* Wrap a v1 route handler with auth, scope, idempotency, dry-run, request-id,
* logging, and v1 error envelope handling.
*
* `operation` is a stable identifier for logs ('companies.list', 'invoices.create'...).
*/
export function withApiV1<P extends DynamicParams = { params: Promise<Record<string, never>> }>(
operation: string,
handler: V1Handler<P>,
options: ApiV1Options = {},
): (request: Request, params: P) => Promise<Response> {
return async function wrapped(request: Request, params: P): Promise<Response> {
const requestId = generateRequestId()
const start = Date.now()
const log = createLogger(`api/v1/${operation}`, { requestId, operation })
const url = new URL(request.url)
const path = url.pathname
const forensic = extractForensicContext(request, log)
try {
// 1. Determine required scope before auth. Public endpoints can skip
// authentication entirely.
const requiredScope = options.requireScope ?? resolveRequiredScope(request.method, path)
if (requiredScope === null) {
log.warn('endpoint not registered', { path, method: request.method, ...forensic })
return await v1ErrorResponseFromCode('NOT_FOUND', log, {
requestId,
details: { path, method: request.method },
})
}
// 2. Public endpoints: invoke handler with an anon context. If a Bearer
// token IS supplied we opportunistically validate it so rate-limiting
// and key attribution are applied — but a missing or invalid token
// does NOT block the request (the route is, by definition, public).
// Falling back to the anon client when unauthenticated keeps the
// least-privilege guarantee: an accidental DB call from a public
// handler hits RLS, not the service role.
if (requiredScope === 'public') {
const token = extractBearerToken(request)
let publicCtx: ApiV1Context = {
requestId,
log,
userId: 'anonymous',
apiKeyId: undefined,
apiKeyName: undefined,
scopes: [],
mode: 'live',
supabase: createAnonClient(),
dryRun: false,
idempotencyKey: null,
}
if (token) {
const auth = await validateApiKey(token)
if (!('error' in auth)) {
publicCtx = {
...publicCtx,
log: log.child({ userId: auth.userId, apiKeyId: auth.apiKeyId, mode: auth.mode }),
userId: auth.userId,
apiKeyId: auth.apiKeyId,
apiKeyName: auth.apiKeyName,
scopes: auth.scopes,
mode: auth.mode,
supabase: createServiceClientNoCookies(),
}
}
// Invalid token on a public route is silently downgraded to anon —
// do not surface 401 since the route doesn't require auth at all.
}
const response = await handler(request, publicCtx, params)
return stampHeaders(response, requestId)
}
// 3. Authenticate via Bearer token.
const token = extractBearerToken(request)
if (!token) {
log.warn('missing bearer token', forensic)
return await v1ErrorResponseFromCode('UNAUTHORIZED', log, { requestId })
}
const auth = await validateApiKey(token)
if ('error' in auth) {
log.warn('api key validation failed', { status: auth.status, reason: auth.error, ...forensic })
const code = auth.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED'
return await v1ErrorResponseFromCode(code, log, { requestId, reason: auth.error })
}
const userLog = log.child({
userId: auth.userId,
apiKeyId: auth.apiKeyId,
mode: auth.mode,
})
// 4. Scope check.
if (!hasScope(auth.scopes, requiredScope)) {
userLog.warn('insufficient scope', {
required: requiredScope,
granted: auth.scopes,
...forensic,
})
return await v1ErrorResponseFromCode('INSUFFICIENT_SCOPE', userLog, {
requestId,
details: { required_scope: requiredScope, granted_scopes: auth.scopes },
})
}
// 5. Resolve URL companyId and verify access.
const resolvedParams = (await params.params) as Record<string, string | string[] | undefined>
const rawCompanyId = resolvedParams.companyId
const companyId = typeof rawCompanyId === 'string' ? rawCompanyId : undefined
const supabase = createServiceClientNoCookies()
if (companyId !== undefined) {
const { data: membership, error: membershipErr } = await supabase
.from('company_members')
.select('company_id, role')
.eq('user_id', auth.userId)
.eq('company_id', companyId)
.maybeSingle()
if (membershipErr) {
userLog.error('failed to resolve company membership', membershipErr as Error)
return await v1ErrorResponseFromCode('INTERNAL_ERROR', userLog, { requestId })
}
if (!membership) {
userLog.warn('user is not a member of company in URL', { companyId, ...forensic })
// 404 (not 403) so we don't leak company existence to unauthorized callers.
return await v1ErrorResponseFromCode('NOT_FOUND', userLog, {
requestId,
details: { companyId },
})
}
}
// 6. Idempotency. Mandatory for state-changing methods when the route
// opts in (or when an Idempotency-Key header is supplied).
const idempotencyKey = request.headers.get(IDEMPOTENCY_HEADER)
const isMutation = REQUIRES_IDEMPOTENCY.has(request.method)
if (options.requireIdempotencyKey && isMutation && !idempotencyKey) {
userLog.warn('missing idempotency key on mutating request')
return await v1ErrorResponseFromCode('VALIDATION_ERROR', userLog, {
requestId,
details: {
issues: [{ field: IDEMPOTENCY_HEADER, message: 'Idempotency-Key header is required for write requests.' }],
},
})
}
// 7. If idempotency-key supplied, check for cached response.
let bodyForHash: unknown = null
let workingRequest = request
if (idempotencyKey && isMutation && companyId) {
const { body, cloned } = await readBodyForHash(request)
bodyForHash = body
workingRequest = cloned
const reqHash = hashRequest({ method: request.method, path, body })
try {
const hit = await checkIdempotencyKey(supabase, auth.userId, companyId, idempotencyKey, reqHash)
if (hit) {
userLog.info('idempotent replay', { idempotencyKey })
const replay = NextResponse.json(hit.body, { status: hit.status === 'success' ? 200 : 400 })
replay.headers.set('Idempotent-Replayed', 'true')
return stampHeaders(replay, requestId)
}
} catch (err) {
if (err instanceof IdempotencyKeyReuseError) {
userLog.warn('idempotency key reused with different body')
return await v1ErrorResponseFromCode('IDEMPOTENCY_KEY_REUSE', userLog, {
requestId,
details: { key: idempotencyKey },
})
}
throw err
}
}
// 8. Dry-run resolution.
const dryRun = isDryRun(workingRequest, url)
const ctx: ApiV1Context = {
requestId,
log: userLog.child({ companyId }),
userId: auth.userId,
apiKeyId: auth.apiKeyId,
apiKeyName: auth.apiKeyName,
scopes: auth.scopes,
mode: auth.mode,
supabase,
companyId,
dryRun,
idempotencyKey,
}
// 9. Invoke handler.
const response = await handler(workingRequest, ctx, params)
// 10. Persist idempotency cache (best-effort).
if (idempotencyKey && isMutation && companyId && response.status < 500) {
try {
const body = await response.clone().json().catch(() => ({}))
const reqHash = hashRequest({ method: request.method, path, body: bodyForHash })
const status: 'success' | 'error' = response.status >= 400 ? 'error' : 'success'
await storeIdempotencyResponse(
supabase,
auth.userId,
companyId,
idempotencyKey,
reqHash,
status,
body as Record<string, unknown>,
'api_route',
)
} catch (err) {
userLog.warn('failed to persist idempotency response', err as Error)
}
}
ctx.log.info('op completed', {
durationMs: Date.now() - start,
status: response.status,
dryRun,
})
return stampHeaders(response, requestId)
} catch (err) {
log.error('op failed', err as Error, { durationMs: Date.now() - start })
return await v1ErrorResponse(err, log, { requestId })
}
}
}
function stampHeaders(response: Response, requestId: string): Response {
if (!response.headers.get('X-Request-Id')) response.headers.set('X-Request-Id', requestId)
if (!response.headers.get(API_V1_VERSION_HEADER)) {
response.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
}
// Apply security headers to every wrapped v1 response — same set as the
// public discovery routes PLUS X-Robots-Tag noai so authenticated payloads
// are excluded from AI training sets (Claude, ChatGPT, Perplexity, Google
// -Extended respect this; others won't).
for (const [k, v] of Object.entries(WRAPPED_RESPONSE_HEADERS)) {
if (!response.headers.get(k)) response.headers.set(k, v)
}
return response
}