Files
accounted/lib/api/v1/with-api-v1.ts
T
Mattsson aabddb592f feat(billing): multi-user paywall: multi_user capability, 20-day grace, owner-only dormancy (#2099)
* feat(billing): multi-user seat gate: multi_user capability, 20-day grace, owner-only dormancy

Multiple people in one company becomes a paid capability (multi_user, the
eighth PAID key). Derived at access time from capability_grants, no status
column, no enforcement cron:

- entitled: active grant (trial/stripe/team/manual/comp), everyone works
- grace: newest grant expired < 20 days ago; countdown banner for everyone
  in companies with > 1 user; invites still allowed
- frozen: only role=owner resolves; other memberships go dormant (rows
  untouched, paying reactivates instantly); invites 403 with paid-plan upsell

Enforcement: new resolve_active_company_gated RPC (zero-arg RPC and RLS twin
untouched: they also run on self-hosts, where the gate never bites), gated
query fallback for service-role/API-key paths, setActiveCompany guard, MCP
company-access check, invite route. Middleware routes all-frozen users to a
new /paused page; the switcher greys locked companies.

Migration 20260901081417 (applied to staging): trial trigger seeds
multi_user, backfills for mid-trial companies, active Stripe subs, team
agreements, and a grandfather grant (expires now, i.e. grace = deploy + 20
days) for existing unpaid multi-member companies. Daily cron mails owners at
grace start and last day. Strings in sv+en; pg-real + unit tests included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): multi-user seat gate hardening from skeptic review

- Stripe cancel now EXPIRES the multi_user stripe grant instead of deleting
  it: the 20-day grace window hangs on an expired row, so a deleted one
  froze churned payers' staff instantly with no banner and no mail. Other
  stripe grants keep the freeze-and-retain delete.
- New SECURITY DEFINER company_multi_user_state() RPC (migration
  20260901083726, applied to staging) and RPC-first getMultiUserState:
  capability_grants RLS hides team-scoped rows from non-team users, so
  user-client reads misread byra-covered companies as frozen (switch
  refusal, wrong switcher locks).
- Byra-kind teams get a standing team-scoped multi_user grant (backfill +
  teams trigger): byra client companies have no company-scoped trial by
  design, so a grantless byra team would freeze every consultant and
  client user.
- Comped/manual companies with active PAID-key grants extend to multi_user
  (a comped company must not read as paying while locking out user two).
- /api/v1 gets the same dormancy gate as MCP (frozen non-owner -> 403).
- PGRST202 on resolution fails OPEN (pre-migration DB has zero multi_user
  rows; the gated fallback would have frozen every non-owner mid-deploy).
- Grace cron: covers team-scoped lapses (byra agreement ending) and skips
  the start mail for the hand-mailed grandfather cohort.
- Tests updated/added across all touched surfaces; pg tests for the new
  RPC and byra trigger; trial-suppression pg test extended to 8 keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): decouple seat-gate env check and fail open on gate read throws

CI round 1 on #2099:
- isMultiUserEnforced no longer imports has-capability: several route test
  suites partially mock that module and the vitest mock guard threw from
  inside the v1 seat gate, turning expected 4xx responses into 500s.
  multi_user is never a connector capability, so the bypass reduces to the
  same env reads, now inlined.
- getMultiUserState wraps its resolution in a fail-open try/catch: a client
  without .rpc or a thrown network error must never lock users out.
- no-phantom-columns ceiling 391 -> 393 with reasons: the seat gate's .or()
  scope filter (server-resolved UUIDs) and the Stripe cancel expiry update's
  timestamp .or(); all columns in both strings are literals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

* fix(billing): membership-guard the multi-user entitlement RPCs (Superagent P3)

company_multi_user_ok and company_multi_user_state are SECURITY DEFINER and
were granted to authenticated with a caller-supplied company UUID: any
logged-in user could probe an arbitrary company's billing state and grace
deadline across tenants. Migration 20260901091752 (applied to staging)
requires an auth.uid() membership in the target company when a JWT is
present, keeps service-role/definer contexts unrestricted, and clamps the
grace window to [0, 20] days. pg tests: stranger gets false/NULL, member
reads normally, oversized p_grace_days cannot widen the probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4tNt8wRG3a5iuU1JE2pnP

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 11:29:12 +02:00

621 lines
26 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 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,
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'])
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<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 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<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: [],
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 },
})
}
// 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 ((membership as { role?: string }).role !== 'owner') {
const access = await getMultiUserState(supabase, companyId)
if (isMembershipDormant((membership as { role: string }).role, 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.
if (idempotencyKey && isMutation && companyId && !dryRun && response.status < 500) {
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<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) {
// 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
}