b68c082ef5
* fix(bank-sync): cron backfills the gap since the last successful sync The daily incremental sync always asked the bank for the last 7 days. Any pause longer than that (a lapsed subscription paid again, a consent renewed after expiry, an outage) silently lost the days in between: the connection came back, looked healthy, and the missing transactions never arrived. The lookback now widens to cover the gap since last_synced_at plus one day of overlap, capped at the 90-day PSD2 limit, and a gap of a month or more asks for strategy=longest like the manual sync route does. Dedup via external_id makes the overlap harmless. First syncs keep their 90-day path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip warns seven days before a bank consent expires The transactions-page chip only reacted once a connection was already dead (expired/error) or had gone stale. A consent that is about to end looked healthy until the morning it stopped syncing. New "expiring" state when a live connection's consent_expires is within seven days, the same threshold as the consent-expiry email in the sync cron. Precedence: attention, expiring, stale, healthy. getChipState moves to lib/transactions/bank-sync-chip-state.ts so the precedence is unit-tested; the component keeps the rendering only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(bank-sync): chip says paused when the subscription lapsed The daily cron filters connections by the bank_sync capability, so a company whose trial or subscription ended keeps status=active rows with a frozen last_synced_at. The chip read that as "stale, check the connection", which sends the user to re-authorise a connection that is perfectly alive. 56 of 191 active connections on prod were in this state on 2026-09-01. New "paused" state, ranked above everything else, when the company lacks bank_sync: hosted points at billing, self-host at the connector key, the same split BankSyncNowButton already makes. getChipState takes an options object so the clock stays out of render (react-hooks/purity). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * feat(api): agent-triggerable bank sync in v1 and MCP Closes the first wish in the F2 report: an integration could read bank data but never refresh it. New POST /api/v1/companies/{id}/bank-connections/ {connectionId}/sync and MCP gnubok_sync_bank, both on a shared runner (extensions/general/enable-banking/lib/trigger-sync.ts). Cost is bounded structurally, not by policy: the window is never caller-controlled (the cron's gap-aware 7 to 90 day lookback), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (429 + Retry-After on v1; synced=false in-band on MCP so the agent reads on instead of retrying), and a failing connection is throttled per process by attempt time. A dead session is flipped to expired with a remediation that hands the user the connect link: no API call revives a consent. Gated on bank_sync like gnubok_connect_bank; scope transactions:write. Registry, scope map, load-routes, spec snapshot and the generated accounted-api skill updated; five BANK_SYNC_* / BANK_SESSION_EXPIRED codes added to the structured-error registry. The web Synka-nu route is left as is (see DECISIONS.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * test(bank-sync): use the options object in the remaining chip-state calls Four multi-line calls still passed the clock positionally after getChipState moved to an options object; tsc flagged them (vitest did not, the extra argument was ignored at runtime). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(api): address skeptic findings on the agent-triggered bank sync Three refutations from the pre-publish skeptic pass: 1. Core imported the extension. The v1 sync route pulled the runner straight from @/extensions, which the core-build gate rejects and which left a live bank endpoint on zero-extension builds. The route now resolves it through the registry's services channel against a contract in lib/bank-sync/trigger-sync-contract.ts (same pattern as the Skatteverket read service) and answers EXTENSION_DISABLED when the extension is absent. 2. The idempotency cache stored the handler-level 429. A same-key retry after Retry-After, which is the documented retry, replayed the stale cooldown as a 400 for the cache's 24-hour TTL. withApiV1 no longer caches 429 responses; regression test added. The endpoint's pitfall no longer claims Idempotency-Key is mandatory (it was never enforced). 3. Two cron tests read the clock twice and failed whenever a millisecond passed between the reads. They now pin the clock with fake timers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdcDV7CngLkWUvfKSsxFhS * fix(bank-sync): durable cooldown lease and review wording Resolves the PR #2165 review findings in one pass. Superagent P1: the attempt throttle was a process-local Map, so two agent calls on different serverless instances (or a retry after a cold start on a failing connection) could each bill an Enable Banking call, contradicting the one-sync-per-15-minutes promise. New bank_connections.sync_lease_until (migration 20260902150000), claimed with one conditional UPDATE before the bank is called; Postgres row locking makes exactly one claimer win, the rest answer BANK_SYNC_COOLDOWN. The lease stays for the full window on success and failure. Tests cover the claim order, a failed attempt seen from a second instance, a lost race, and an expired lease. CodeRabbit: the =1 plural branch now reads "in 1 day" / "om 1 dag" (daysUntilConsentExpiry rounds a partial day up, so "tomorrow" could be today); the cooldown pitfall on the v1 endpoint, the MCP description and the in-band cooldown instruction now say a cooldown can follow a failed attempt and tell the agent to compare last_synced_at before deciding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): lease claim as a literal filter for the schema guard CI's no-phantom-columns guard counts runtime-built query expressions and its ceiling is exact; the templated `.or('sync_lease_until.is.null,...')` claim added one. The column now defaults to epoch (NOT NULL), so "never claimed" is just "expired long ago" and the atomic claim is a single literal `.lte('sync_lease_until', now)` the guard can check. Migration is unshipped (same PR), so it is edited in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub * fix(bank-sync): runner verifies company membership before the lease Superagent (round 3): the MCP path reached the shared runner without a membership check of its own. Both callers do enforce it upstream (withApiV1's company resolution and resolveMcpCompanyContext in the MCP dispatcher), but the runner writes transactions and bills a bank call, so it now checks company_members itself, before the cooldown and the lease claim, and answers NOT_FOUND for a non-member. The viewer check that was buried inside the sync block moves up with it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125TMQQBjBBZG9YxP7wQWub --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
683 lines
29 KiB
TypeScript
683 lines
29 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. 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<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 },
|
|
})
|
|
}
|
|
|
|
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<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
|
|
}
|