* feat(api): v1 customer writes (Phase 2 PR-B-1)
First slice of Phase 2 PR-B from the agent-native v1 plan. Customer writes
are the simplest write surface — no journal entries, no PDF, no email —
which makes them the right place to validate the dry-run + idempotency
pipeline before applying it to invoice flows in PR-B-2.
New endpoints:
- POST /api/v1/companies/:companyId/customers (idempotent, dry-runnable)
- PATCH /api/v1/companies/:companyId/customers/:id (idempotent, dry-runnable)
- DELETE /api/v1/companies/:companyId/customers/:id (soft-delete via
archived_at; idempotent re-archiving; dry-runnable; 204)
All three require customers:write scope (added to V1_ENDPOINT_SCOPES). All
three require Idempotency-Key (mandatory — wrapper option
requireIdempotencyKey: true). All three accept ?dry_run=true or
X-Dry-Run: true and return a 200 OK preview with X-Dry-Run header set.
New shared infrastructure:
- lib/api/v1/dry-run.ts — dryRunPreview() (validation-only, no staging) and
dryRunStaged() (financial writes; populated in later phases). Defines the
preview response shape that POST/PATCH/DELETE share. Future financial
writes (invoices, journal entries) will reuse the staged variant.
Pre-existing PR-A bugs fixed:
- CustomerType enum in customers/route.ts had wrong values ('business',
'eu_individual', 'non_eu'). Canonical enum is ['individual',
'swedish_business', 'eu_business', 'non_eu_business']. Fixed.
- INDIVIDUAL_TYPES masking referenced non-existent 'eu_individual'.
Only 'individual' refers to a natural person (Swedish sole trader where
org_number = personnummer).
Wrapper bug fix:
- The idempotency body-hashing flow was consuming the original request's
body before passing it to the handler. In Node's vitest environment the
cloned request's body became empty, so the handler's request.json()
returned {}. Fix: read body from a clone for hashing, leave the
original intact for the handler.
VIES re-validation on PATCH preserves existing best-effort behaviour.
Customer.created event emission on POST so future webhook delivery
(Phase 2 PR-C) can subscribe.
23 new tests covering happy path, dry-run preview, idempotency-key
enforcement, scope checks, UUID validation, duplicate-org conflict,
soft-delete semantics. 3150/3150 vitest pass; build clean; lint clean
on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #452 review (Greptile + swarm + Swedish compliance)
Real bugs (all reviewers agreed):
- POST registerEndpoint example used the old customer_type 'business'
enum value that this PR was already fixing. Now uses 'swedish_business'
consistently between code, schema, and docs.
- DELETE docstring claimed "Refuses to delete if open invoices remain"
but the handler unconditionally archived. Swedish compliance flagged
this as a real ML 17 kap 24§ concern (archived customer + open
invoice can block kreditfaktura issuance). Added the pre-flight check:
DELETE now returns 409 CUSTOMER_HAS_INVOICES with the open count when
any open invoice (sent / partially_paid / overdue) references the
customer. Docstring updated to match.
- PATCH advertised archived_at: null for un-archive but did not actually
apply it (field missing from updateData iteration). New
V1PatchCustomerSchema extends UpdateCustomerSchema with archived_at
restricted to literal null — agents can un-archive but cannot fake
archive timestamps. The PATCH allowlist now includes archived_at.
Defensive cleanups:
- POST: VIES validation now resolves BEFORE the insert so
vat_number_validated is set atomically in the primary write. Eliminates
the stale-response window where the response could show
vat_number_validated=false even though the secondary update succeeded.
- PATCH: same pattern — VIES re-validation folded into the primary
update payload. Single round-trip; response always reflects committed
DB state.
- CUSTOMER_RESPONSE_COLUMNS dropped vat_number_validated_at (internal
timestamp; not declared in the CustomerCreated or CustomerDetail Zod
schemas; no documented consumer).
Pushing back on:
- V8.2.1 cross-tenant membership check — false positive. The wrapper
performs the company_members check before invoking the handler
(lib/api/v1/with-api-v1.ts ~232). The swarm read handlers in isolation.
- A.8.11 PATCH response masking for individual customer_types —
deliberate detail-endpoint carve-out per PR-A. List masks, detail
doesn't; that's the design.
- CC6.3 separate customers:delete scope — every accounting API
(Stripe, QuickBooks, Fortnox) conflates write + archive. Splitting
violates principle of least surprise.
- V4.5 schema allowlist enforcement — Zod already strips unknown keys;
defense-in-depth at the DB-write layer is redundant.
- A.5.34 eu_individual masking documentation — the value never existed
in canonical CustomerTypeSchema; PR-A's enum was a hallucination this
PR corrects. Nothing to document beyond the code comment that's now
in place.
- Swedish: country default 'Sweden' wrong for non-Swedish customer types —
breaking schema change; defer.
- Swedish: VAT-format regex pre-check before VIES — internal
/api/customers route doesn't either; consistency over micro-validation.
- Swedish: flag existing reverse-charge invoices when VIES turns
vat_number_validated=false — substantial cross-resource workflow;
defer to PR-B-2 or a dedicated compliance-tooling PR.
- CC7.2 customer.updated / customer.archived event emission — adding new
event types touches lib/events/types.ts AND the event-log-handler
allowlist; defer to PR-C where webhooks will be the consumer.
3 new tests cover: archive-blocked-by-open-invoices, PATCH archived_at
un-archive, PATCH archived_at rejects non-null. 3153/3153 vitest pass;
build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): second-pass review on PR #452 — defense-in-depth tweaks
Two small adjustments after the second compliance-swarm sweep (13 →
expected 11 findings, 0 blocking after this commit):
- GDPR Art.5(1)(c) defense-in-depth: re-add 'eu_individual' to the
INDIVIDUAL_TYPES masking set in the customer LIST handler. The value
is not in the canonical CustomerTypeSchema (so new customers can never
have it), but the `customer_type` DB column carries no CHECK constraint,
so legacy rows from earlier schema iterations could in principle hold
it. Masking is free when the value never appears and protective if it
ever does. Adding 'eu_individual' as a first-class customer_type for
EU natural persons remains a separate product decision the Swedish
compliance review surfaced.
- ISO 27001:2022 A.8.33: test bootstrap now asserts NODE_ENV === 'test'.
Supabase clients are fully mocked, but if a future test refactor
accidentally bypassed the mock, this guard fails the run rather than
letting fixtures reach production.
Stale comments from prior sweep — no action needed, fixes already in
commit 5bb63489:
- Greptile P1 "DELETE doesn't check open invoices" — handler now does
(lines ~430-440 of [id]/route.ts); the inline comment is pinned to
the original file lines and hasn't auto-resolved.
- Greptile P1 "PATCH archived_at silently ignored" — V1PatchCustomerSchema
now accepts archived_at: z.null().optional() and the field is in the
iteration list.
- Greptile P1 "example uses old enum" — updated to 'swedish_business'.
False positive called out:
- OWASP V2.4 "dry-run DELETE skips the open-invoice pre-check" — the
pre-flight runs BEFORE the dry-run branch ([id]/route.ts ~430-445),
so dry-run DELETE on a customer with open invoices DOES return 409.
Pushing back (consistent with first-pass triage):
- V8.2.1 × 3 cross-tenant check — wrapper does it (with-api-v1.ts ~232);
false positive.
- V2.3 / CC6.3 archive scope split — every accounting API conflates
write + archive.
- V4.5 customer_type cross-field invariants — schema-level cross-field
validation; defer.
- V16 transactional outbox — architectural; defer to PR-C webhooks.
- Art.25 + A.5.12 + A.5.34 PATCH/dry-run preview masking — single-record
detail context, deliberate carve-out per PR-A.
- Swedish 'disputed' status — not in canonical InvoiceStatus enum.
- Swedish 3-state VIES validation, personnummer-format check, mandatory
country for non-SE types — all schema-level / cross-field; defer.
3153/3153 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): don't echo org_number in 409 conflict response (GDPR Art.5(1)(c))
For customer_type='individual', org_number IS the Swedish personnummer.
The 409 CUSTOMER_DUPLICATE_ORG_NUMBER error detail previously included
the submitted value, transmitting it through:
- The HTTP response body
- Server / observability logs
- Any HTTP intermediary recording bodies
The caller already knows what they submitted; the error code + a
{ field: 'org_number' } hint is enough. Drops the value from the detail
in both POST /customers and PATCH /customers/:id.
3153/3153 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
454 lines
18 KiB
TypeScript
454 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 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: [],
|
|
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
|
|
}
|