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

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

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

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

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

Address compliance-swarm findings on PR #450:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3029/3029 vitest tests pass.

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

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

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

3031/3031 vitest tests pass; build green.

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

---------

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

176 lines
5.7 KiB
TypeScript

/**
* v1 REST error envelope.
*
* Wraps the existing structured-error machinery (lib/errors/get-structured-error)
* into the v1-specific shape that agents consume:
*
* {
* error: {
* code: machine-readable, stable forever
* message: Swedish prose
* message_en: English prose (agents prefer this)
* details: structured context (pgCode, field issues, period_id...)
* recovery_hint: natural-language next step the agent can act on
* docs_url: canonical error-doc URL
* valid_alternatives: hints like { unlock_endpoint, next_open_period, ...}
* request_id: correlation id, echoed in X-Request-Id header
* }
* }
*
* The first three fields exist on the legacy `getStructuredError` output.
* `recovery_hint`, `docs_url`, `valid_alternatives` are additive — derived from
* the registry's `remediation` block (when present) plus a per-code doc-URL
* derivation rule.
*/
import { NextResponse } from 'next/server'
import {
errorResponse as legacyErrorResponse,
errorResponseFromCode as legacyErrorResponseFromCode,
} from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import type { Logger } from '@/lib/logger'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
const DOCS_BASE = process.env.NEXT_PUBLIC_APP_URL
? `${process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '')}/docs/api/errors`
: '/docs/api/errors'
export interface V1ErrorBody {
error: {
code: string
message: string
message_en?: string
details?: unknown
recovery_hint?: string
docs_url?: string
valid_alternatives?: Record<string, unknown>
request_id?: string
}
}
export interface V1ErrorContext {
requestId: string
/** Extra structured context for the agent (period_id, customer_id, ...). */
details?: unknown
/** Override the http status from the registry entry. */
status?: number
/** Agent-actionable next-step suggestions: { unlock_endpoint, next_open_period }. */
validAlternatives?: Record<string, unknown>
}
function docsUrlFor(code: string): string {
return `${DOCS_BASE}/${code}`
}
/**
* Transform a legacy error envelope from `errorResponse()` into the v1 shape.
*
* The legacy shape is:
* { error: { code, message, message_en?, remediation?, requestId?, details? } }
*
* v1 needs:
* { error: { code, message, message_en?, details?, recovery_hint?, docs_url, valid_alternatives?, request_id? } }
*
* The remediation.description becomes recovery_hint; docs_url is derived from
* the code; valid_alternatives is passed through unchanged.
*/
async function rewriteEnvelope(
legacyResponse: NextResponse,
ctx: V1ErrorContext,
): Promise<NextResponse> {
const status = ctx.status ?? legacyResponse.status
const body = (await legacyResponse.json().catch(() => null)) as
| { error: { code: string; message: string; message_en?: string; remediation?: { description?: string }; details?: unknown } }
| null
if (!body?.error) {
// Should never happen — legacyErrorResponse always returns the envelope.
const fallback: V1ErrorBody = {
error: {
code: 'INTERNAL_ERROR',
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
message_en: 'Internal server error.',
docs_url: docsUrlFor('INTERNAL_ERROR'),
request_id: ctx.requestId,
},
}
return finalize(NextResponse.json(fallback, { status }), ctx)
}
const { code, message, message_en, remediation, details } = body.error
const v1Body: V1ErrorBody = {
error: {
code,
message,
...(message_en ? { message_en } : {}),
...(details !== undefined ? { details } : {}),
...(remediation?.description ? { recovery_hint: remediation.description } : {}),
docs_url: docsUrlFor(code),
...(ctx.validAlternatives ? { valid_alternatives: ctx.validAlternatives } : {}),
request_id: ctx.requestId,
},
}
return finalize(NextResponse.json(v1Body, { status }), ctx)
}
function finalize(res: NextResponse, ctx: V1ErrorContext): NextResponse {
res.headers.set('X-Request-Id', ctx.requestId)
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
return res
}
/**
* v1 error response from a thrown value. Dispatches through the legacy
* machinery for code resolution, then rewrites into the v1 shape.
*
* Always logs the underlying error; never throws.
*/
export async function v1ErrorResponse(
err: unknown,
log: Logger,
ctx: V1ErrorContext,
): Promise<NextResponse> {
const legacy = legacyErrorResponse(err, log, {
requestId: ctx.requestId,
details: ctx.details,
status: ctx.status,
})
return rewriteEnvelope(legacy, ctx)
}
/**
* v1 error response from a known code (no thrown value involved).
*
* Use this when the route already knows the failure mode:
*
* return v1ErrorResponseFromCode('PERIOD_LOCKED', log, {
* requestId: ctx.requestId,
* details: { period_id, locked_at },
* validAlternatives: { unlock_endpoint: '/v1/.../fiscal-periods/:id:unlock' },
* })
*/
export async function v1ErrorResponseFromCode(
code: string,
log: Logger,
ctx: V1ErrorContext & { reason?: string },
): Promise<NextResponse> {
const legacy = legacyErrorResponseFromCode(code, log, {
requestId: ctx.requestId,
details: ctx.details,
status: ctx.status,
reason: ctx.reason,
})
return rewriteEnvelope(legacy, ctx)
}
/**
* Quick check: does this code map to a registered entry? Used by callers that
* want to validate a code before throwing it (e.g. registry-driven dispatch).
*/
export function isRegisteredV1Code(code: string): boolean {
return getErrorEntry(code) !== undefined
}