/** * 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_`) 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. A `viewer` * (read-only) membership is refused for every write: mutating method * or non-`:read` scope (FORBIDDEN, details.code ROLE_READ_ONLY). * 5. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header). * 6. Resolves `Idempotency-Key` (header) and replays cached responses. The * dry-run flag is part of the cache identity and dry-run responses are * never cached, so a simulation can never be replayed in place of the * real write that follows it. * 7. Invokes the handler with a typed RouteContext. * 8. Stamps `X-Request-Id` and `Gnubok-Version` on the response, plus * `Retry-After` on a 429. * 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 { type SupabaseClient } from '@supabase/supabase-js' import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { truncateIp } from '@/lib/api/ip' import { type ApiKeyMode, type ApiKeyScope, createServiceClientNoCookies, extractBearerToken, hasScope, RATE_LIMIT_RETRY_AFTER_SECONDS, scopeKind, validateApiKey, } from '@/lib/auth/api-keys' import { runWithActor } from '@/lib/bookkeeping/actor-context-node' // Per CLAUDE.md: any route that emits events via eventBus must call // ensureInitialized() at module level to wire extension event handlers // (email, cloud-backup, push-notifications, etc.). Calling it here in the // wrapper guarantees every v1 route gets the init at import time: a single // source of truth so future routes can't forget. The function itself is // idempotent (guarded by a module-level boolean). ensureInitialized() import { resolveRequiredScope } from '@/lib/auth/scopes' import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user' import { getEndpointByConcretePath } from './registry' 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' // Every state-changing method. PUT is included even though most v1 writes are // POST/PATCH: the set drives THREE behaviors (test-key dry-run forcing, // idempotency replay, requireIdempotencyKey enforcement), and omitting PUT // would let test keys write through PUT routes for real. const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) // RFC 9110 safe methods. The read-only role gate treats EVERYTHING else as a // write: a superset of REQUIRES_IDEMPOTENCY, so an exotic method can never // slip a viewer past the gate. Deliberately separate from REQUIRES_IDEMPOTENCY, // whose semantics (replay, dry-run forcing) must not widen as a side effect. const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) /** The read-only company role (see `CompanyRole` in `@/types`). */ const READ_ONLY_ROLE = 'viewer' 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[] /** * Largest amount in SEK this key may commit with no human approving it, or * null for no ceiling (the default, and what every key predating the column * has). Enforced at the two places an API key can post money: this surface's * journal-entries.commit, and commitPendingOperation for the MCP path. */ unattendedCommitLimit: number | null /** * test|live. Test keys are simulation-only: the wrapper forces `dryRun` on * for every write, so handlers never need to special-case `mode`; they just * honor `dryRun` as usual. */ 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> } | { params: Promise<{}> } type V1Handler

> }> = ( request: Request, ctx: ApiV1Context, params: P, ) => Promise 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 createServiceRoleClient(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 { truncateIp } 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 { // Case-insensitive on BOTH surfaces. The header was always lowercased, but // the query flag used to require exactly 'true', so '?dry_run=True' // committed for real while the caller believed it previewed: the worst // possible parse of a preview flag. Any other value ('1', 'yes', 'false') // stays non-dry-run, unchanged. const queryVal = url.searchParams.get('dry_run') if (queryVal !== null && queryVal.toLowerCase() === 'true') return true const headerVal = request.headers.get(DRY_RUN_HEADER) if (headerVal && headerVal.toLowerCase() === 'true') return true return false } /** * Canonical idempotency hash for a v1 request. * * `dryRun` is part of the identity of the request. The documented commit flow * (see `dry-run.ts`) is "re-issue the exact same request without * `dry_run=true`, same Idempotency-Key", so a simulation and the real write * that follows it share method, path and body. Hashing only those three made * the two indistinguishable: the preview got cached under the commit's hash * and the commit replayed it, returning 200 with `{ dry_run: true, preview }` * while writing nothing. * * The flag is only added to the hashed object when it is TRUE, so an ordinary * (non-dry-run) write hashes byte-identically to previous releases. Idempotency * rows live for 24h; folding `dry_run: false` in unconditionally would make * every key in flight across this deploy fail the `request_hash` comparison and * answer a legitimate retry with IDEMPOTENCY_KEY_REUSE. * * Both the cache lookup and the cache store go through this function: if the * two hash inputs ever drift apart, a stored response can never be found * again, which is a subtler failure than the one this fixes. */ function buildRequestHash(input: { method: string path: string body: unknown dryRun: boolean }): string { return hashRequest({ method: input.method, path: input.path, body: input.body, ...(input.dryRun ? { dry_run: true } : {}), }) } async function readBodyForHash(request: Request): Promise<{ body: unknown; cloned: Request }> { // We need the body to hash it, but the handler also needs it. Read from a // CLONE for the hash and pass the original through to the handler: that // way the handler's `await request.json()` still works regardless of how // the runtime implements stream teeing. const reader = request.clone() const text = await reader.text() if (!text) return { body: null, cloned: request } try { return { body: JSON.parse(text), cloned: request } } catch { return { body: text, cloned: request } } } /** * 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

> }>( operation: string, handler: V1Handler

, options: ApiV1Options = {}, ): (request: Request, params: P) => Promise { return async function wrapped(request: Request, params: P): Promise { 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: [], unattendedCommitLimit: null, 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, unattendedCommitLimit: auth.unattendedCommitLimit, 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 rateLimited = auth.status === 429 const code = rateLimited ? 'RATE_LIMITED' : 'UNAUTHORIZED' return await v1ErrorResponseFromCode(code, log, { requestId, reason: auth.error, ...(rateLimited ? { retryAfterSeconds: RATE_LIMIT_RETRY_AFTER_SECONDS } : {}), }) } 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. // // Next.js 16 invokes a route handler with `{ params: undefined }` for a // STATIC route (no `[segment]` in the path): see app-route module.js // `handlerContext = { params: context.params ? ... : undefined }`. The // only authenticated static route on this surface is `/api/v1/companies`, // so awaiting `params.params` blindly null-derefs there (`undefined` has // no `.companyId`) and the catch below turns it into a 500 for every // valid key. Dynamic routes still pass a real `Promise<{ companyId }>`. // Guard the await and default to an empty param set. 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 }, }) } const membershipRole = (membership as { role?: string }).role // Read-only role gate. Cookie routes enforce the viewer role through // withRouteContext({ requireWrite }) and the DB enforces it through // RLS + triggers for cookie sessions, but this surface runs as the // service role: nothing below this line would stop a viewer's key from // posting. A request is a write when EITHER its method is unsafe // (catches action verbs whatever their scope) OR its scope is an // elevated grant (catches reads-by-method that still manage tenant // state, e.g. GET /webhooks on webhooks:manage). Dry-run and test // keys are refused too: a viewer has no write to simulate. // // Placed AFTER the membership 404 so a non-member sees exactly what it // saw before (no new company-existence signal), and BEFORE the seat // gate so a refused write costs no extra read. if ( membershipRole === READ_ONLY_ROLE && (!SAFE_METHODS.has(request.method) || scopeKind(requiredScope) === 'write') ) { userLog.warn('read-only membership refused write request', { companyId, method: request.method, requiredScope, ...forensic, }) return await v1ErrorResponseFromCode('FORBIDDEN', userLog, { requestId, status: 403, reason: 'role_read_only', details: { code: 'ROLE_READ_ONLY', companyId, role: READ_ONLY_ROLE, required_scope: requiredScope, message: 'This company membership is read-only (viewer): write requests are refused. Ask a company owner or admin to change the role.', }, }) } // Multi-user seat gate: the API-key surface is a chokepoint like the // cookie routes and MCP. A non-owner membership in a frozen company // (multi_user lapsed past its 20-day grace) is refused here so an old // key cannot keep working the books after the freeze. Owners pass // without the extra read; the service client sees team-scoped grants. if (membershipRole !== 'owner') { const access = await getMultiUserState(supabase, companyId) if (isMembershipDormant(membershipRole as string, access.state)) { userLog.warn('multi-user seat gate refused frozen membership', { companyId, ...forensic }) return await v1ErrorResponseFromCode('FORBIDDEN', userLog, { requestId, status: 403, reason: 'multi_user_frozen', details: { companyId, capability: 'multi_user', message: 'Company is paused for this account: multiple users require a paid plan. Ask the company owner to upgrade.', }, }) } } } // 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) // Test keys are simulation-only: every write is forced to dry-run so // nothing persists (the credential bakes in `?dry_run=true`). A mutating // endpoint that can't be simulated (dryRunSupported=false, or unregistered) // would otherwise write for real: block it outright so a test key can // never touch real data. Reads pass through unchanged (real data, no write). let forceDryRun = false if (auth.mode === 'test' && isMutation) { const endpoint = getEndpointByConcretePath(request.method, path) if (!endpoint || !endpoint.dryRunSupported) { userLog.warn('test key blocked from non-simulatable endpoint', { path, method: request.method, ...forensic, }) return await v1ErrorResponseFromCode('TEST_KEY_WRITE_BLOCKED', userLog, { requestId, details: { path, method: request.method }, }) } forceDryRun = true } 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. Dry-run resolution. Test keys force it on regardless of the flag. // Resolved BEFORE the idempotency lookup because it feeds the request // hash: a preview and its follow-up commit must never share a cache // entry. const dryRun = isDryRun(request, url) || forceDryRun // 8. 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 = buildRequestHash({ method: request.method, path, body, dryRun }) 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 } } const ctx: ApiV1Context = { requestId, log: userLog.child({ companyId }), userId: auth.userId, apiKeyId: auth.apiKeyId, apiKeyName: auth.apiKeyName, scopes: auth.scopes, unattendedCommitLimit: auth.unattendedCommitLimit, mode: auth.mode, supabase, companyId, dryRun, idempotencyKey, } // 9. Invoke handler, inside the commit-actor scope. // // commitEntry() reads getActor() as its fallback and forwards it to the // commit_journal_entry RPC, which stamps journal_entries.committed_actor_* // and the audit_log COMMIT row (migration 20260619120000). Wrapping here // rather than threading a parameter means EVERY v1 write is attributed, // including the ones that reach the ledger through a helper several // frames down (reverseEntry, correctEntry, the supplier-invoice paths). // // Before this, runWithActor had exactly ONE production call site, the // pending-operations commit. Everything committing outside that path was // anonymous: on production, 99.8% of storno entries and 100% of // correction entries carried no actor at all, which are precisely the two // sanctioned rättelse paths under BFL 5 kap. 5 § and the place where // "who did this, and when" is a legal question rather than a nicety. // // `api_key` is the honest label for this surface: a gnubok_sk_ bearer // token. The OAuth/MCP surfaces set their own actor and are unaffected. const response = await runWithActor( { type: 'api_key', label: auth.apiKeyName ?? 'Unnamed API key' }, () => handler(workingRequest, ctx, params), ) // Signal test mode on every test-key response so integrators can see the // request was simulation-only without inspecting the body. if (ctx.mode === 'test') { response.headers.set('X-Gnubok-Mode', 'test') } // 10. Persist idempotency cache (best-effort). // // Never cache a dry-run: the response describes a write that did not // happen, and caching it under a real Idempotency-Key is exactly how // the documented "preview, then commit with the same key" flow used // to lose the commit. A simulation has nothing worth replaying. // // Never cache a 429 either: a throttle says "not now", and replaying // it under the same key would turn a 15-minute cooldown into the // cache's 24-hour TTL (the documented retry is "same request after // Retry-After", which is exactly a same-key retry). if ( idempotencyKey && isMutation && companyId && !dryRun && response.status < 500 && response.status !== 429 ) { try { const body = await response.clone().json().catch(() => ({})) const reqHash = buildRequestHash({ method: request.method, path, body: bodyForHash, dryRun, }) const status: 'success' | 'error' = response.status >= 400 ? 'error' : 'success' await storeIdempotencyResponse( supabase, auth.userId, companyId, idempotencyKey, reqHash, status, body as Record, '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) { // Resolve the envelope first so the log level can follow the mapped // status — thrown 4xx domain errors are expected outcomes (warn), only // 5xx are runtime errors. v1ErrorResponse logs the error itself at the // same threshold. const response = await v1ErrorResponse(err, log, { requestId }) const meta = { durationMs: Date.now() - start, status: response.status } if (response.status < 500) log.warn('op failed', err as Error, meta) else log.error('op failed', err as Error, meta) return response } } } 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 }