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>
This commit is contained in:
Jakob Wennberg
2026-05-12 20:47:13 +02:00
committed by GitHub
parent 980f29dae8
commit db592d922d
23 changed files with 2708 additions and 0 deletions
+54
View File
@@ -37,6 +37,7 @@ function RegisterPageContent() {
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isRegistered, setIsRegistered] = useState(false)
const [duplicateEmail, setDuplicateEmail] = useState<string | null>(null)
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null)
const [bankIdSessionId, setBankIdSessionId] = useState<string | null>(null)
@@ -277,6 +278,15 @@ function RegisterPageContent() {
return
}
// Supabase obfuscates duplicate signups (to prevent user enumeration):
// when the email already belongs to a confirmed account, it returns
// data.user with identities: [] and no error, and sends no email.
// Detect that case so we don't show a misleading "check your email" screen.
if (data.user && (data.user.identities?.length ?? 0) === 0) {
setDuplicateEmail(emailValue)
return
}
setEmail(emailValue)
setIsRegistered(true)
} catch (error) {
@@ -297,6 +307,50 @@ function RegisterPageContent() {
}
}
if (duplicateEmail) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kontot finns redan</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Det finns redan ett konto kopplat till{' '}
<span className="font-medium text-foreground">{duplicateEmail}</span>.
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via &quot;Glömt lösenord?&quot; inloggningssidan.
</p>
</div>
<div className="space-y-2">
<Button className="w-full" asChild>
<Link href={`/login?email=${encodeURIComponent(duplicateEmail)}`}>
Logga in
</Link>
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setDuplicateEmail(null)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
</div>
)
}
if (isRegistered) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
@@ -0,0 +1,40 @@
/**
* /.well-known/skills/index.json — workflow catalogue exposed at a well-known URL.
*
* Mirrors the MCP `gnubok_list_skills` tool: a flat array of skill descriptors
* (slug, name, summary, tags) that external agents can discover without
* speaking MCP. Each skill's full Markdown body remains gated behind MCP +
* authentication; this index is public summary metadata only.
*/
import { NextResponse } from 'next/server'
import { skills } from '@/extensions/general/mcp-server/skills'
import { API_V1_VERSION } from '@/lib/api/v1/version'
import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
export async function GET(_request: Request) {
const base = getCanonicalBaseUrl()
const catalogue = {
schema_version: '1',
api_version: API_V1_VERSION,
docs_url: `${base}/docs/api`,
mcp_endpoint: `${base}/api/extensions/ext/mcp-server/mcp`,
skills: skills.map((s) => ({
slug: s.slug,
name: s.name,
summary: s.summary,
tags: s.tags,
/** MCP resource URI; load via the MCP server's resources/read with an authenticated key. */
uri: `gnubok://skill/${s.slug}`,
})),
}
return NextResponse.json(catalogue, {
status: 200,
headers: withPublicSecurityHeaders({
'Cache-Control': 'public, max-age=300, s-maxage=300',
}),
})
}
+198
View File
@@ -0,0 +1,198 @@
/**
* GET /api/v1/companies — list companies the calling API key can access.
*
* The API key is bound to a user; that user may be a member of multiple
* companies (consultant-style). This endpoint returns every company the user
* has a non-archived membership for, in stable created_at order.
*
* Used by 3rd-party integrations to discover which company IDs to scope
* subsequent calls to.
*/
import { z } from 'zod'
import { paginated } from '@/lib/api/v1/response'
import {
encodeDefaultCursor,
parsePaginationParams,
decodeDefaultCursor,
} from '@/lib/api/v1/pagination'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse } from '@/lib/api/v1/errors'
const Company = z.object({
id: z.string().uuid(),
name: z.string(),
org_number: z.string().nullable(),
entity_type: z.string(),
role: z.enum(['owner', 'admin', 'member', 'viewer']),
created_at: z.string(),
})
const CompaniesListResponse = z.object({
companies: z.array(Company),
})
registerEndpoint({
operation: 'companies.list',
method: 'GET',
path: '/api/v1/companies',
summary: 'List companies the API key can access.',
description:
'Returns every non-archived company the API key user is a member of, together with their role. ' +
'Use the returned `id` as `{companyId}` in subsequent endpoints.',
useWhen:
'You need to discover which company IDs an API key has access to before calling company-scoped endpoints.',
doNotUseFor:
'Fetching a single company you already know the id of — use GET /api/v1/companies/{companyId} for that.',
pitfalls: [
'Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths.',
'Archived companies are excluded; if a company disappears the user has been removed from it or it was archived.',
],
example: {
response: {
data: [
{
id: '8fd5b1f4-…',
name: 'Acme AB',
org_number: '556677-8899',
entity_type: 'aktiebolag',
role: 'owner',
created_at: '2025-01-04T08:00:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'companies:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: CompaniesListResponse },
})
export const GET = withApiV1('companies.list', async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
// Authorization boundary: the query below filters by `user_id = ctx.userId`
// BEFORE the cursor's keyset is applied, so a tampered cursor can only
// reorder rows the caller is already entitled to see. Cursors are not
// signed; that's intentional. See PR #450 review for the trade-off.
//
// Keyset pagination uses (joined_at ASC, id ASC) on `company_members`. Two
// memberships sharing a `joined_at` (bulk-imported, concurrent registrations)
// are disambiguated by the `company_members.id` tiebreaker so rows on a page
// boundary are never skipped or duplicated.
// We over-fetch by one to determine whether a next page exists.
let query = ctx.supabase
.from('company_members')
.select(
`
id,
role,
joined_at,
companies:company_id (
id,
name,
org_number,
entity_type,
archived_at,
created_at
)
`,
)
.eq('user_id', ctx.userId)
.is('companies.archived_at', null)
.order('joined_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (decoded) {
// Compound keyset: joined_at > cursor.ts OR (joined_at = cursor.ts AND id > cursor.id).
// `.or()` takes a comma-separated PostgREST filter string.
query = query.or(
`joined_at.gt.${decoded.ts},and(joined_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type CompanyRow = {
id: string
name: string
org_number: string | null
entity_type: string
archived_at: string | null
created_at: string
}
type Row = {
// `company_members.id` — the membership row's own UUID, used as the
// cursor's secondary key. Not exposed in the response.
id: string
role: 'owner' | 'admin' | 'member' | 'viewer'
joined_at: string
// PostgREST returns the joined company as either an object (one-to-one FK
// resolution) or an array. Accept both — Supabase's auto-typing chooses
// the array shape, but actual responses for a single-row FK are objects.
companies: CompanyRow | CompanyRow[] | null
}
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const pickCompany = (r: Row): CompanyRow | null => {
if (!r.companies) return null
return Array.isArray(r.companies) ? (r.companies[0] ?? null) : r.companies
}
// Defense in depth: PostgREST's `.is('companies.archived_at', null)` is
// expected to filter out archived companies before the row reaches us.
// If a `company_members` row arrives without an associated company object,
// the join filter behaved differently than expected — drop the row AND
// surface it as a warn so we notice silent data-integrity regressions.
let droppedNulls = 0
const companies = trimmed
.map((r) => {
const c = pickCompany(r)
if (!c) {
droppedNulls += 1
return null
}
return {
id: c.id,
name: c.name,
org_number: c.org_number,
entity_type: c.entity_type,
role: r.role,
created_at: c.created_at,
}
})
.filter((x): x is NonNullable<typeof x> => x !== null)
if (droppedNulls > 0) {
ctx.log.warn('companies.list: dropped rows with null company join', { droppedNulls })
}
// Cursor encodes the LAST row's `(joined_at, company_members.id)` — always
// present, no null-guard needed. Independent of whether the joined company
// dropped out of the response shape.
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.joined_at })
: null
return paginated(companies, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* GET /api/v1/health — public health check.
*
* Returns service status. No auth required. Used by load balancers, uptime
* checks, and as a smoke test for the v1 wrapper itself.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { API_V1_VERSION } from '@/lib/api/v1/version'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
const HealthResponse = z.object({
status: z.enum(['ok', 'degraded']),
service: z.literal('gnubok'),
api_version: z.string(),
timestamp: z.string(),
})
registerEndpoint({
operation: 'health.check',
method: 'GET',
path: '/api/v1/health',
summary: 'Health check.',
description: 'Reports the API is reachable and what version is currently served. Public; no auth required.',
useWhen: 'You want to verify connectivity, latency, or which API version is live before issuing other requests.',
doNotUseFor: 'Anything that needs authenticated data. This endpoint returns no company-specific information.',
pitfalls: [
'A 200 here only means the API process responds — downstream Postgres/Supabase may still be degraded.',
],
example: {
response: {
data: {
status: 'ok',
service: 'gnubok',
api_version: '2026-05-12',
timestamp: '2026-05-12T16:25:06Z',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: null,
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: HealthResponse },
})
export const GET = withApiV1('health.check', async (_request, ctx) => {
return ok(
{
status: 'ok' as const,
service: 'gnubok' as const,
api_version: API_V1_VERSION,
timestamp: new Date().toISOString(),
},
{ requestId: ctx.requestId },
)
})
+23
View File
@@ -0,0 +1,23 @@
/**
* GET /api/v1/openapi.json — public OpenAPI 3.1 spec for the v1 surface.
*
* Generated from the Zod schema registry at request time. Cached for 5
* minutes in shared caches.
*/
import { NextResponse } from 'next/server'
import { generateOpenApiSpec } from '@/lib/api/v1/registry'
import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
import '@/lib/api/v1/load-routes'
export async function GET(_request: Request) {
const spec = generateOpenApiSpec(getCanonicalBaseUrl())
return NextResponse.json(spec, {
status: 200,
headers: withPublicSecurityHeaders({
'Cache-Control': 'public, max-age=300, s-maxage=300',
}),
})
}
+75
View File
@@ -0,0 +1,75 @@
/**
* /llms.txt — agent-discoverable index of the gnubok API.
*
* Convention adopted by Stripe, Anthropic, and other agent-facing platforms:
* a plain-text Markdown file at the doc root that points LLM crawlers and
* IDE agents (Cursor, Claude Code, Windsurf) at the canonical resources
* they need. Cheaper than scraping HTML.
*/
import { NextResponse } from 'next/server'
import { API_V1_VERSION } from '@/lib/api/v1/version'
import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers'
import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url'
export async function GET(_request: Request) {
const base = getCanonicalBaseUrl()
const body = `# gnubok API
> Swedish double-entry bookkeeping as a public REST API. API version ${API_V1_VERSION}.
This API lets agents and integrations do anything the gnubok dashboard can do —
read transactions, create invoices, mark them paid, run VAT reports, file year-end
declarations, ingest SIE files, and subscribe to webhooks for state changes.
## Quickstart
1. Create an API key in the gnubok dashboard at /settings/api.
2. Authenticate with \`Authorization: Bearer gnubok_sk_<live|test>_<random>\`.
3. List companies the key can access: \`GET ${base}/api/v1/companies\`.
4. Use the returned \`id\` as \`{companyId}\` in subsequent paths.
## Core principles
- **Dry-run on every write.** Add \`?dry_run=true\` or \`X-Dry-Run: true\` to any
POST/PATCH/DELETE to preview the effect (journal lines, voucher number,
account deltas) without committing. The same call without dry-run commits.
- **Idempotency-Key on every write.** Pass a UUID in \`Idempotency-Key\`; replays
return the cached response (24h TTL) with \`Idempotent-Replayed: true\`.
- **Test mode.** API keys prefixed \`gnubok_sk_test_\` are bound to deterministic
sandbox companies — safe for evals and agent learning. Live keys hit real data.
- **Compliance pre-flight.** \`GET /api/v1/companies/{id}/compliance/check?type=…\`
returns structured findings (voucher gaps, locked-period violations, VAT close
blockers, missing receipts) before you submit.
## Resources
- OpenAPI 3.1 spec: ${base}/api/v1/openapi.json
- Skills catalogue: ${base}/.well-known/skills/index.json
- Health check: ${base}/api/v1/health
- Docs (cookbook + reference): ${base}/docs/api
- Error reference: ${base}/docs/api/errors
- Security disclosure policy: ${base}/SECURITY.md (responsible disclosure to security@arcim.io)
## Schema discovery
Every \`.md\` URL under /docs/api is served as plain Markdown so agents can
ingest it without HTML parsing.
## Versioning
The URL major version is \`/api/v1/\`. Within v1, the response shape is pinned to
\`${API_V1_VERSION}\`. Future breaking changes inside v1 will accept an optional
\`Gnubok-Version: YYYY-MM-DD\` header for opt-in upgrades; older versions keep
working until explicitly retired.
`
return new NextResponse(body, {
status: 200,
headers: withPublicSecurityHeaders({
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=300',
}),
})
}
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LIMIT,
MAX_LIMIT,
decodeDefaultCursor,
encodeDefaultCursor,
nextCursorFromPage,
parsePaginationParams,
} from '../pagination'
describe('parsePaginationParams', () => {
it('returns defaults when no params present', () => {
const url = new URL('https://example.com/v1/x')
expect(parsePaginationParams(url)).toEqual({ limit: DEFAULT_LIMIT, cursor: null })
})
it('parses limit and clamps to MAX_LIMIT', () => {
const url = new URL('https://example.com/v1/x?limit=999')
expect(parsePaginationParams(url).limit).toBe(MAX_LIMIT)
})
it('floors limit to default for non-numeric input', () => {
const url = new URL('https://example.com/v1/x?limit=abc')
expect(parsePaginationParams(url).limit).toBe(DEFAULT_LIMIT)
})
it('treats limit=0 as invalid → DEFAULT_LIMIT', () => {
const url = new URL('https://example.com/v1/x?limit=0')
expect(parsePaginationParams(url).limit).toBe(DEFAULT_LIMIT)
})
it('returns the cursor verbatim when supplied', () => {
const url = new URL('https://example.com/v1/x?cursor=abc123')
expect(parsePaginationParams(url).cursor).toBe('abc123')
})
})
describe('default cursor encode/decode', () => {
it('round-trips a row', () => {
const row = { id: '8fd5b1f4-1234-1234-1234-1234567890ab', created_at: '2026-05-12T16:00:00Z' }
const cur = encodeDefaultCursor(row)
expect(cur).not.toBeNull()
expect(decodeDefaultCursor(cur)).toEqual({ ts: row.created_at, id: row.id })
})
it('returns null for null input', () => {
expect(encodeDefaultCursor(null)).toBeNull()
})
it('returns null when decoding a malformed cursor', () => {
expect(decodeDefaultCursor('not-base64-or-json')).toBeNull()
expect(decodeDefaultCursor('')).toBeNull()
expect(decodeDefaultCursor(null)).toBeNull()
})
it('returns null when decoding a JSON object missing fields', () => {
const cursor = Buffer.from(JSON.stringify({ foo: 'bar' })).toString('base64url')
expect(decodeDefaultCursor(cursor)).toBeNull()
})
})
describe('nextCursorFromPage', () => {
const ID_A = '11111111-1111-1111-1111-111111111111'
const ID_B = '22222222-2222-2222-2222-222222222222'
const ID_C = '33333333-3333-3333-3333-333333333333'
const row = (id: string, ts: string) => ({ id, created_at: ts })
it('returns null when the page is not full', () => {
const rows = [
row(ID_A, '2026-01-01T00:00:00Z'),
row(ID_B, '2026-01-02T00:00:00Z'),
]
expect(nextCursorFromPage(rows, 5)).toBeNull()
})
it('returns a cursor when there are more rows than the limit', () => {
const rows = [
row(ID_A, '2026-01-01T00:00:00Z'),
row(ID_B, '2026-01-02T00:00:00Z'),
row(ID_C, '2026-01-03T00:00:00Z'),
]
const cursor = nextCursorFromPage(rows, 2)
expect(cursor).not.toBeNull()
expect(decodeDefaultCursor(cursor)).toEqual({ ts: '2026-01-03T00:00:00Z', id: ID_C })
})
})
describe('decodeDefaultCursor — strict format validation', () => {
const validId = '8fd5b1f4-1234-1234-1234-1234567890ab'
const validTs = '2026-05-12T16:00:00Z'
const cursorFor = (payload: object): string =>
Buffer.from(JSON.stringify(payload)).toString('base64url')
it('rejects a cursor whose ts is a date-only string', () => {
const cur = cursorFor({ ts: '2026-05-12', id: validId })
expect(decodeDefaultCursor(cur)).toBeNull()
})
it('rejects a cursor whose id is not a UUID', () => {
const cur = cursorFor({ ts: validTs, id: 'not-a-uuid' })
expect(decodeDefaultCursor(cur)).toBeNull()
})
it('rejects a cursor with a SQL-injection-shaped ts value', () => {
const cur = cursorFor({ ts: "'); drop table api_keys; --", id: validId })
expect(decodeDefaultCursor(cur)).toBeNull()
})
it('accepts a well-formed cursor', () => {
const cur = cursorFor({ ts: validTs, id: validId })
expect(decodeDefaultCursor(cur)).toEqual({ ts: validTs, id: validId })
})
it('accepts ts with timezone offset and fractional seconds', () => {
const ts = '2026-05-12T16:00:00.123+02:00'
const cur = cursorFor({ ts, id: validId })
expect(decodeDefaultCursor(cur)).toEqual({ ts, id: validId })
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { accepted, created, noContent, ok, paginated } from '../response'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from '../version'
describe('v1 response helpers', () => {
const requestId = 'req_abc'
it('ok() wraps data with meta and stamps standard headers', async () => {
const res = ok({ hello: 'world' }, { requestId })
expect(res.status).toBe(200)
expect(res.headers.get('X-Request-Id')).toBe(requestId)
expect(res.headers.get(API_V1_VERSION_HEADER)).toBe(API_V1_VERSION)
const body = await res.json()
expect(body).toEqual({
data: { hello: 'world' },
meta: { request_id: requestId, api_version: API_V1_VERSION },
})
})
it('paginated() includes next_cursor when provided', async () => {
const res = paginated([{ id: 1 }], { requestId, nextCursor: 'cur_xyz' })
const body = await res.json()
expect(body.meta.next_cursor).toBe('cur_xyz')
expect(body.data).toEqual([{ id: 1 }])
})
it('paginated() omits next_cursor when not provided', async () => {
const res = paginated([], { requestId })
const body = await res.json()
expect(body.meta.next_cursor).toBeUndefined()
})
it('created() returns 201', () => {
const res = created({}, { requestId })
expect(res.status).toBe(201)
})
it('accepted() returns 202 with operation_id + poll_url', async () => {
const res = accepted('op_123', 'import.sie', { requestId })
expect(res.status).toBe(202)
const body = await res.json()
expect(body.data).toEqual({
operation_id: 'op_123',
type: 'import.sie',
status: 'queued',
poll_url: '/api/v1/operations/op_123',
webhook_event: 'operation.completed',
})
})
it('noContent() returns 204 with no body', async () => {
const res = noContent({ requestId })
expect(res.status).toBe(204)
expect(res.headers.get('X-Request-Id')).toBe(requestId)
})
it('stamps Idempotent-Replayed when set', () => {
const res = ok({}, { requestId, idempotentReplay: true })
expect(res.headers.get('Idempotent-Replayed')).toBe('true')
})
it('stamps X-Dry-Run when set', () => {
const res = ok({}, { requestId, dryRun: true })
expect(res.headers.get('X-Dry-Run')).toBe('true')
})
it('stamps rate-limit headers when supplied', () => {
const reset = new Date(1_700_000_000_000)
const res = ok({}, { requestId, rateLimit: { limit: 100, remaining: 42, resetAt: reset } })
expect(res.headers.get('X-RateLimit-Limit')).toBe('100')
expect(res.headers.get('X-RateLimit-Remaining')).toBe('42')
expect(res.headers.get('X-RateLimit-Reset')).toBe(String(Math.floor(reset.getTime() / 1000)))
})
it('includes audit block in meta for write responses', async () => {
const res = ok({}, {
requestId,
audit: { voucher_number: 'A2026-0042', immutable_at: '2026-05-12T16:00:00Z' },
})
const body = await res.json()
expect(body.meta.audit).toEqual({
voucher_number: 'A2026-0042',
immutable_at: '2026-05-12T16:00:00Z',
})
})
})
+498
View File
@@ -0,0 +1,498 @@
/**
* Integration tests for the v1 wrapper.
*
* Mocks `validateApiKey` and `createServiceClientNoCookies` so we can exercise
* the wrapper's auth / scope / company-membership / idempotency / dry-run
* branches deterministically.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
beforeAll(() => {
// The wrapper's public-scope path now fails closed if these env vars are
// missing; tests don't run against a real Supabase instance so we stub
// values just to clear the guard.
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
// The wrapper's public-scope path calls @supabase/supabase-js#createClient
// directly to obtain an anon-key client (no service-role privilege).
// Stub it so tests don't need real SUPABASE env vars.
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return {
...actual,
createClient: vi.fn().mockReturnValue({}),
}
})
vi.mock('@/lib/api/idempotency', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
'@/lib/api/idempotency',
)
return {
...actual,
checkIdempotencyKey: vi.fn(),
storeIdempotencyResponse: vi.fn(),
}
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
checkIdempotencyKey,
storeIdempotencyResponse,
} from '@/lib/api/idempotency'
import { truncateIp, withApiV1 } from '../with-api-v1'
import { ok } from '../response'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCheckIdempotency = checkIdempotencyKey as ReturnType<typeof vi.fn>
const mockStoreIdempotency = storeIdempotencyResponse as ReturnType<typeof vi.fn>
function makeSupabaseStub(membership: { company_id: string; role: string } | null) {
return {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
}),
}),
}),
}),
}
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, init)
}
// Helper that wraps an empty params promise for non-dynamic routes.
function emptyParams() {
return { params: Promise.resolve({}) }
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
})
describe('withApiV1 — auth', () => {
it('returns 401 when Authorization header is missing', async () => {
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(makeRequest('https://x.test/api/v1/companies'), emptyParams())
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
expect(body.error.request_id).toMatch(/^req_/)
})
it('returns 401 when validateApiKey rejects the token', async () => {
mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 })
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies', {
headers: { Authorization: 'Bearer gnubok_sk_invalid' },
}),
emptyParams(),
)
expect(res.status).toBe(401)
})
it('returns 429 when the underlying key is rate-limited', async () => {
mockValidate.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 })
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
emptyParams(),
)
expect(res.status).toBe(429)
const body = await res.json()
expect(body.error.code).toBe('RATE_LIMITED')
})
})
describe('withApiV1 — scope', () => {
it('returns 403 INSUFFICIENT_SCOPE when the key lacks the required scope', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
apiKeyId: 'ak_1',
apiKeyName: 'test key',
scopes: ['invoices:read'], // wrong scope
mode: 'live',
})
const handler = withApiV1('companies.list', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
emptyParams(),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
expect(body.error.details.required_scope).toBe('companies:read')
})
it('returns 404 NOT_FOUND for unregistered endpoints (no leak)', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['companies:read'],
mode: 'live',
})
const handler = withApiV1('mystery.endpoint', async (_req, ctx) =>
ok({ ok: true }, { requestId: ctx.requestId }),
)
const res = await handler(
makeRequest('https://x.test/api/v1/mystery', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
emptyParams(),
)
expect(res.status).toBe(404)
})
})
describe('withApiV1 — company membership', () => {
it('returns 404 when the URL companyId is not a company the user belongs to', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['companies:read'],
mode: 'live',
})
// No membership → null
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
const handler = withApiV1(
'companies.get',
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
{ requireScope: 'companies:read' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/other-company', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('other-company'),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('allows the request when the user has membership in the URL company', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['companies:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
const handler = withApiV1(
'companies.get',
async (_req, ctx) => ok({ companyId: ctx.companyId }, { requestId: ctx.requestId }),
{ requireScope: 'companies:read' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.companyId).toBe('company-1')
})
})
describe('withApiV1 — idempotency', () => {
it('replays a cached response when the idempotency key matches', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
mockCheckIdempotency.mockResolvedValue({
status: 'success',
body: { data: { id: 'inv-cached' } },
})
const handler = withApiV1(
'invoices.create',
async () => {
return NextResponse.json({ data: { id: 'inv-fresh' } }, { status: 201 })
},
{ requireScope: 'invoices:write' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Idempotency-Key': 'key-1',
'Content-Type': 'application/json',
},
body: JSON.stringify({ customer_id: 'cust-1' }),
}),
companyParams('company-1'),
)
expect(res.headers.get('Idempotent-Replayed')).toBe('true')
const body = await res.json()
expect(body.data.id).toBe('inv-cached')
})
it('honors the require-idempotency-key option', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
const handler = withApiV1(
'invoices.create',
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
{ requireScope: 'invoices:write', requireIdempotencyKey: true },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
companyParams('company-1'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('withApiV1 — dry-run', () => {
it('threads dry_run=true from query string into context', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
let observedDryRun: boolean | null = null
const handler = withApiV1(
'invoices.create',
async (_req, ctx) => {
observedDryRun = ctx.dryRun
return ok({ ok: true }, { requestId: ctx.requestId, dryRun: ctx.dryRun })
},
{ requireScope: 'invoices:write' },
)
const res = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices?dry_run=true', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
}),
companyParams('company-1'),
)
expect(observedDryRun).toBe(true)
expect(res.headers.get('X-Dry-Run')).toBe('true')
})
it('threads X-Dry-Run header into context', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
let observedDryRun: boolean | null = null
const handler = withApiV1(
'invoices.create',
async (_req, ctx) => {
observedDryRun = ctx.dryRun
return ok({ ok: true }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'X-Dry-Run': 'true',
'Content-Type': 'application/json',
},
body: '{}',
}),
companyParams('company-1'),
)
expect(observedDryRun).toBe(true)
})
})
describe('withApiV1 — public endpoints', () => {
it('invokes the handler without authentication for /api/v1/health', async () => {
let observedUserId: string | null = null
const handler = withApiV1('health.check', async (_req, ctx) => {
observedUserId = ctx.userId
return ok({ status: 'ok' }, { requestId: ctx.requestId })
})
const res = await handler(makeRequest('https://x.test/api/v1/health'), emptyParams())
expect(res.status).toBe(200)
expect(observedUserId).toBe('anonymous')
})
it('opportunistically attributes a valid Bearer token on a public route', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['companies:read'],
mode: 'live',
})
let observedUserId: string | null = null
let observedApiKeyId: string | undefined
const handler = withApiV1('health.check', async (_req, ctx) => {
observedUserId = ctx.userId
observedApiKeyId = ctx.apiKeyId
return ok({ status: 'ok' }, { requestId: ctx.requestId })
})
const res = await handler(
makeRequest('https://x.test/api/v1/health', {
headers: { Authorization: 'Bearer gnubok_sk_x' },
}),
emptyParams(),
)
expect(res.status).toBe(200)
expect(observedUserId).toBe('user-1')
expect(observedApiKeyId).toBe('ak_1')
})
it('silently downgrades an invalid Bearer token to anon on a public route', async () => {
mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 })
let observedUserId: string | null = null
const handler = withApiV1('health.check', async (_req, ctx) => {
observedUserId = ctx.userId
return ok({ status: 'ok' }, { requestId: ctx.requestId })
})
const res = await handler(
makeRequest('https://x.test/api/v1/health', {
headers: { Authorization: 'Bearer gnubok_sk_invalid' },
}),
emptyParams(),
)
expect(res.status).toBe(200)
expect(observedUserId).toBe('anonymous')
})
})
describe('withApiV1 — stable headers', () => {
it('always stamps X-Request-Id and Gnubok-Version', async () => {
const handler = withApiV1('health.check', async (_req, ctx) =>
ok({ status: 'ok' }, { requestId: ctx.requestId }),
)
const res = await handler(makeRequest('https://x.test/api/v1/health'), emptyParams())
expect(res.headers.get('X-Request-Id')).toMatch(/^req_/)
expect(res.headers.get('Gnubok-Version')).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('truncateIp — privacy-preserving IP logging', () => {
it('truncates IPv4 to /24', () => {
expect(truncateIp('203.0.113.42')).toBe('203.0.113.0/24')
})
it('truncates IPv6 to /48', () => {
expect(truncateIp('2001:db8:abcd:1234::1')).toBe('2001:db8:abcd::/48')
})
it('returns undefined for an empty IP', () => {
expect(truncateIp(undefined)).toBeUndefined()
expect(truncateIp('')).toBeUndefined()
})
it('returns undefined for malformed input rather than leaking it raw', () => {
expect(truncateIp('not-an-ip')).toBeUndefined()
})
it('rejects IPv4 with out-of-range octets to avoid pseudo-IPs in audit logs', () => {
expect(truncateIp('999.999.999.999')).toBeUndefined()
expect(truncateIp('256.0.0.1')).toBeUndefined()
expect(truncateIp('192.168.1.300')).toBeUndefined()
})
it('accepts edge IPv4 octets (0 and 255)', () => {
expect(truncateIp('0.0.0.0')).toBe('0.0.0.0/24')
expect(truncateIp('255.255.255.255')).toBe('255.255.255.0/24')
})
})
// Suppress unused-import warning — we re-export to keep the type chain visible.
void mockStoreIdempotency
+20
View File
@@ -0,0 +1,20 @@
/**
* Canonical base URL for v1 surfaces.
*
* Use this — NOT `new URL(request.url).host` — when assembling URLs that go
* into discovery files, OpenAPI specs, or any response a 3rd-party agent
* caches. The inbound `Host` header is attacker-controlled at the edge; a
* spoofed value would otherwise poison agent discovery and redirect them
* to attacker-controlled endpoints.
*
* Honours `NEXT_PUBLIC_APP_URL` (the same env var the rest of the codebase
* uses for absolute links). Falls back to `https://localhost:3000` for
* local development when the env var is unset — this is a deliberate
* fail-closed default that any production deploy will override.
*/
export function getCanonicalBaseUrl(): string {
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim()
if (fromEnv) return fromEnv.replace(/\/$/, '')
return 'http://localhost:3000'
}
+175
View File
@@ -0,0 +1,175 @@
/**
* 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
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Side-effect import that ensures every v1 route module's top-level
* `registerEndpoint()` call has been executed before the OpenAPI generator
* reads the registry.
*
* Why this exists: route files register themselves at module load time. The
* OpenAPI endpoint runs in its own module which would otherwise not pull in
* the other route files. Importing them here as side-effects populates the
* shared `ENDPOINTS` map.
*
* When a new v1 route is added, append a `import '...'` line.
*/
// Phase 1 surface.
import '@/app/api/v1/health/route'
import '@/app/api/v1/companies/route'
export {}
+124
View File
@@ -0,0 +1,124 @@
/**
* Cursor-based pagination for v1 list endpoints.
*
* Cursors are opaque base64-JSON tokens encoding the keyset position. The
* default key is `(created_at, id)`, which is stable across concurrent writes
* — a row inserted after a cursor was minted appears in a later page, never
* mid-page. Endpoints that need a different sort key supply their own
* encoder/decoder pair.
*
* Limits are clamped to [1, 100]; default 50.
*
* Cursors are NOT signed or encrypted: they reveal only sort-key values that
* the user could already see from a previous page. Treat them as ephemeral
* pagination hints, not security tokens.
*/
import { z } from 'zod'
export const DEFAULT_LIMIT = 50
export const MAX_LIMIT = 100
export interface PaginationParams {
limit: number
cursor: string | null
}
/**
* Parse `?cursor=...&limit=...` from a URL. Returns a normalized
* { limit, cursor } pair with limit clamped to [1, MAX_LIMIT].
*
* Invalid `limit` (non-numeric, negative) falls back to DEFAULT_LIMIT rather
* than throwing — callers can always re-validate via Zod if strictness is
* needed.
*/
export function parsePaginationParams(url: URL): PaginationParams {
const rawLimit = url.searchParams.get('limit')
const cursor = url.searchParams.get('cursor')
let limit = DEFAULT_LIMIT
if (rawLimit !== null) {
const parsed = Number.parseInt(rawLimit, 10)
if (Number.isFinite(parsed) && parsed > 0) {
limit = Math.min(parsed, MAX_LIMIT)
}
}
return { limit, cursor: cursor && cursor.length > 0 ? cursor : null }
}
/**
* Schema for query-param validation when a route already uses Zod for inputs.
*/
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
cursor: z.string().min(1).optional(),
})
// ─────────────────────────────────────────────────────────────────
// Default (created_at, id) keyset cursor
// ─────────────────────────────────────────────────────────────────
export interface DefaultCursor {
/** ISO 8601 timestamp of the boundary row's created_at. */
ts: string
/** UUID of the boundary row. Disambiguates rows with identical timestamps. */
id: string
}
/**
* Encode a (created_at, id) boundary into an opaque cursor string.
* Returns null when the input is null/undefined so callers can write
* `next_cursor: encodeDefaultCursor(lastRow)` without a conditional.
*/
export function encodeDefaultCursor(row: { created_at: string; id: string } | null | undefined): string | null {
if (!row) return null
const payload: DefaultCursor = { ts: row.created_at, id: row.id }
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
}
// Strict format guards — defence-in-depth against tampered cursors that
// could otherwise inject untyped strings into a query's `.gt(field, value)`.
// PostgREST would likely reject these, but validating here keeps the failure
// mode predictable (stale cursor → "start over") rather than 400-ing.
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* Decode a cursor produced by encodeDefaultCursor. Returns null when the
* input is missing or malformed — callers should treat null as "start from
* the beginning" rather than 400-ing on a stale cursor.
*
* `ts` must parse as an ISO 8601 timestamp and `id` must be a UUID; anything
* else is treated as a stale/corrupt cursor and discarded.
*/
export function decodeDefaultCursor(cursor: string | null | undefined): DefaultCursor | null {
if (!cursor) return null
try {
const json = Buffer.from(cursor, 'base64url').toString('utf8')
const parsed = JSON.parse(json) as Partial<DefaultCursor>
if (typeof parsed.ts !== 'string' || typeof parsed.id !== 'string') return null
if (!ISO_TIMESTAMP.test(parsed.ts)) return null
if (!UUID.test(parsed.id)) return null
return { ts: parsed.ts, id: parsed.id }
} catch {
return null
}
}
/**
* Convenience: given a result page and the requested limit, return the
* cursor for the *next* page (or null when this was the final page).
*
* Convention: the caller fetches `limit + 1` rows, passes the full slice in,
* and we return either the cursor of row[limit] or null when the page wasn't
* full. The caller should then trim the slice to `limit` before returning it
* to the user.
*/
export function nextCursorFromPage<T extends { created_at: string; id: string }>(
rows: T[],
limit: number,
): string | null {
if (rows.length <= limit) return null
return encodeDefaultCursor(rows[limit])
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Single source of truth for the v1 REST surface.
*
* Every endpoint registers its Zod request/response schemas + agent-facing
* metadata (description, use-when, do-not-use-for, pitfalls, example) +
* OpenAPI `x-*` extensions (`x-action-risk`, `x-idempotent`, `x-reversible`,
* `x-dry-run-supported`).
*
* Three artefacts are derived from this registry:
* 1. The OpenAPI 3.1 spec at /api/v1/openapi.json (this file).
* 2. The MCP tool list (future — Phase 5).
* 3. Runtime validators (Zod itself, used by handlers).
*
* Phase 1 ships a minimal Zod→JSON-Schema converter. Phase 2 will swap in
* `@asteasolutions/zod-to-openapi` once the schema surface justifies the
* dependency. The registry shape stays stable across that change.
*/
import type { ZodTypeAny } from 'zod'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
import { API_V1_VERSION } from './version'
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
export type ActionRisk = 'low' | 'medium' | 'high'
export interface EndpointDefinition {
/** HTTP method + path pattern, e.g. 'GET /api/v1/companies'. */
operation: string
method: HttpMethod
path: string
/** One-sentence summary; first sentence of the OpenAPI description. */
summary: string
/** Longer prose for the docs and the registered MCP tool description. */
description: string
/** Positive trigger — when should an agent reach for this endpoint? */
useWhen: string
/** Negative trigger — what looks similar but isn't this. */
doNotUseFor: string
/** Common pitfalls. Bullet-list style; agents see this in tool docs. */
pitfalls: string[]
/** One worked example (used by the contract-test suite). */
example: {
request?: Record<string, unknown>
response: Record<string, unknown>
}
/** Required scope; null for public endpoints. */
scope: ApiKeyScope | null
/** Action risk — informs whether the agent should confirm before calling. */
risk: ActionRisk
/** True for GET requests and well-known idempotent writes. */
idempotent: boolean
/** True for writes that can be undone by a single subsequent call (e.g. credit invoice). */
reversible: boolean
/** True for write endpoints that accept ?dry_run=true. */
dryRunSupported: boolean
/** Optional Zod schemas. */
request?: {
/** Path params (companyId, id, ...). */
params?: ZodTypeAny
/** Query params. */
query?: ZodTypeAny
/** Request body. */
body?: ZodTypeAny
}
response: {
/** Successful response body. */
success: ZodTypeAny
/** Stable error codes this endpoint can emit (cross-referenced with the docs). */
errorCodes?: string[]
}
}
const ENDPOINTS = new Map<string, EndpointDefinition>()
/**
* Register an endpoint. Called from the route file at module load time:
*
* registerEndpoint({
* operation: 'companies.list',
* method: 'GET',
* path: '/api/v1/companies',
* ...
* })
*
* The wrapper does not depend on registration — scope resolution lives in
* `lib/auth/scopes.ts` so a missing register() call only affects docs, not
* runtime auth. CI test asserts every wrapped route appears in the registry.
*/
export function registerEndpoint(def: EndpointDefinition): void {
const key = `${def.method} ${def.path}`
if (ENDPOINTS.has(key)) {
// Duplicate registration is a bug — log loudly. Throwing during a route
// module's top-level eval would break unrelated routes; warn instead.
// eslint-disable-next-line no-console
console.warn(`[api/v1/registry] duplicate endpoint registration: ${key}`)
}
ENDPOINTS.set(key, def)
}
export function listEndpoints(): EndpointDefinition[] {
return Array.from(ENDPOINTS.values())
}
export function getEndpoint(method: HttpMethod, path: string): EndpointDefinition | undefined {
return ENDPOINTS.get(`${method} ${path}`)
}
// ──────────────────────────────────────────────────────────────────
// Minimal Zod → JSON Schema converter
// ──────────────────────────────────────────────────────────────────
// Phase 1 only registers a handful of endpoints with simple schemas. We
// implement just enough to cover them: object, string, number, boolean,
// uuid, array, optional, enum, literal, date-string. When the registry
// surface grows past Phase 2, swap this for @asteasolutions/zod-to-openapi.
interface JsonSchema {
type?: string | string[]
properties?: Record<string, JsonSchema>
required?: string[]
items?: JsonSchema
enum?: unknown[]
const?: unknown
format?: string
description?: string
additionalProperties?: boolean | JsonSchema
}
function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
const def = (schema as unknown as { _def: { typeName?: string; type?: string } })._def
// Zod 4 uses string discriminators on _def.type ('string', 'object', etc.).
// Fall back to the legacy typeName for cross-version safety.
const discriminator = def.type ?? def.typeName ?? ''
switch (discriminator) {
case 'string':
case 'ZodString':
return { type: 'string' }
case 'number':
case 'ZodNumber':
return { type: 'number' }
case 'boolean':
case 'ZodBoolean':
return { type: 'boolean' }
case 'array':
case 'ZodArray': {
const inner = (def as { element?: ZodTypeAny; type?: ZodTypeAny }).element
?? (def as { type?: ZodTypeAny }).type
return { type: 'array', items: inner ? zodToJsonSchema(inner) : {} }
}
case 'optional':
case 'ZodOptional':
case 'nullable':
case 'ZodNullable': {
const inner = (def as { innerType: ZodTypeAny }).innerType
return zodToJsonSchema(inner)
}
case 'object':
case 'ZodObject': {
const shape = (schema as unknown as { shape: Record<string, ZodTypeAny> }).shape
const properties: Record<string, JsonSchema> = {}
const required: string[] = []
for (const [key, value] of Object.entries(shape)) {
properties[key] = zodToJsonSchema(value)
const valueDef = (value as unknown as { _def: { typeName?: string; type?: string } })._def
const valueDisc = valueDef.type ?? valueDef.typeName ?? ''
if (valueDisc !== 'optional' && valueDisc !== 'ZodOptional') {
required.push(key)
}
}
return {
type: 'object',
properties,
...(required.length > 0 ? { required } : {}),
additionalProperties: false,
}
}
case 'enum':
case 'ZodEnum': {
const enumDef = def as { values?: unknown[]; entries?: Record<string, unknown> }
const values =
enumDef.values ??
(enumDef.entries ? Object.values(enumDef.entries) : [])
return { type: 'string', enum: values }
}
case 'literal':
case 'ZodLiteral': {
const value = (def as { value?: unknown; values?: unknown[] }).value
?? (def as { values?: unknown[] }).values?.[0]
return { const: value }
}
case 'union':
case 'ZodUnion': {
// Best-effort: emit a oneOf with each member converted. No top-level
// `type` constraint — the individual branches carry their own types
// (valid JSON Schema for a union).
const options = (def as { options?: ZodTypeAny[] }).options ?? []
return { oneOf: options.map(zodToJsonSchema) } as unknown as JsonSchema
}
default:
// Unknown construct → empty schema, accept anything.
return {}
}
}
// ──────────────────────────────────────────────────────────────────
// OpenAPI 3.1 spec generation
// ──────────────────────────────────────────────────────────────────
interface OpenApiSpec {
openapi: '3.1.0'
info: { title: string; version: string; description: string }
servers: Array<{ url: string }>
components: { securitySchemes: Record<string, unknown> }
security: Array<Record<string, unknown[]>>
paths: Record<string, Record<string, unknown>>
}
const SCHEME_NAME = 'ApiKey'
export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
const paths: OpenApiSpec['paths'] = {}
for (const def of ENDPOINTS.values()) {
// OpenAPI path syntax: {param} instead of :param.
const openApiPath = def.path.replace(/:([^/]+)/g, '{$1}')
const responseSchema = zodToJsonSchema(def.response.success)
const operationDef: Record<string, unknown> = {
operationId: def.operation,
summary: def.summary,
description: [
def.description,
'',
`**Use when:** ${def.useWhen}`,
`**Do not use for:** ${def.doNotUseFor}`,
...(def.pitfalls.length > 0 ? ['', '**Pitfalls:**', ...def.pitfalls.map((p) => `- ${p}`)] : []),
].join('\n'),
'x-action-risk': def.risk,
'x-idempotent': def.idempotent,
'x-reversible': def.reversible,
'x-dry-run-supported': def.dryRunSupported,
...(def.scope ? { 'x-required-scope': def.scope } : {}),
responses: {
'200': {
description: 'Success',
content: { 'application/json': { schema: responseSchema } },
},
'400': { description: 'Validation error', $ref: '#/components/responses/Error' },
'401': { description: 'Unauthorized', $ref: '#/components/responses/Error' },
'403': { description: 'Insufficient scope', $ref: '#/components/responses/Error' },
'404': { description: 'Not found', $ref: '#/components/responses/Error' },
'429': { description: 'Rate limited', $ref: '#/components/responses/Error' },
'500': { description: 'Internal error', $ref: '#/components/responses/Error' },
},
}
if (!paths[openApiPath]) paths[openApiPath] = {}
paths[openApiPath][def.method.toLowerCase()] = operationDef
}
return {
openapi: '3.1.0',
info: {
title: 'gnubok API',
version: API_V1_VERSION,
description:
'Public REST API for gnubok — Swedish double-entry bookkeeping. ' +
'Every write supports dry-run via `?dry_run=true`. Every request must include ' +
'`Authorization: Bearer gnubok_sk_...`. See /docs/api for the cookbook.',
},
servers: [{ url: serverUrl }],
components: {
securitySchemes: {
[SCHEME_NAME]: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'gnubok_sk_<live|test>_<random>',
},
},
},
security: [{ [SCHEME_NAME]: [] }],
paths,
}
}
/**
* Test-only escape hatch. Clears the registry — used in unit tests so a test
* that registers a fake endpoint doesn't leak into the next test.
*/
export function _resetRegistryForTests(): void {
ENDPOINTS.clear()
}
+125
View File
@@ -0,0 +1,125 @@
/**
* v1 REST response envelopes.
*
* ok(data) → 200 { data, meta: { request_id, api_version } }
* paginated(data, next_cursor) → 200 { data, meta: { request_id, api_version, next_cursor } }
* accepted(operationId, type) → 202 { data: { operation_id, status, poll_url, webhook_event }, meta }
* created(data) → 201 same shape as ok
*
* Every helper stamps X-Request-Id + Gnubok-Version on the response and
* accepts an optional `audit` block for write responses (per the architectural
* decision in the plan that writes return their voucher_number / audit_url
* inline so the agent doesn't need a second round-trip).
*/
import { NextResponse } from 'next/server'
import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
export interface AuditBlock {
voucher_number?: string
voucher_url?: string
audit_trail_url?: string
immutable_at?: string
}
export interface ResponseMeta {
request_id: string
api_version: string
next_cursor?: string
audit?: AuditBlock
}
interface ResponseOptions {
requestId: string
status?: number
headers?: Record<string, string>
audit?: AuditBlock
/** Cursor for the *next* page; omitted when this is the last page. */
nextCursor?: string
/** Marks the response as a replay of a previously-cached idempotent call. */
idempotentReplay?: boolean
/** Marks the response as a dry-run preview rather than a committed write. */
dryRun?: boolean
/** Rate-limit headers, when known. */
rateLimit?: { limit: number; remaining: number; resetAt?: Date }
}
function applyStandardHeaders(res: NextResponse, opts: ResponseOptions): NextResponse {
res.headers.set('X-Request-Id', opts.requestId)
res.headers.set(API_V1_VERSION_HEADER, API_V1_VERSION)
if (opts.idempotentReplay) res.headers.set('Idempotent-Replayed', 'true')
if (opts.dryRun) res.headers.set('X-Dry-Run', 'true')
if (opts.rateLimit) {
res.headers.set('X-RateLimit-Limit', String(opts.rateLimit.limit))
res.headers.set('X-RateLimit-Remaining', String(opts.rateLimit.remaining))
if (opts.rateLimit.resetAt) {
res.headers.set('X-RateLimit-Reset', String(Math.floor(opts.rateLimit.resetAt.getTime() / 1000)))
}
}
if (opts.headers) {
for (const [k, v] of Object.entries(opts.headers)) {
res.headers.set(k, v)
}
}
return res
}
function buildMeta(opts: ResponseOptions): ResponseMeta {
const meta: ResponseMeta = {
request_id: opts.requestId,
api_version: API_V1_VERSION,
}
if (opts.nextCursor) meta.next_cursor = opts.nextCursor
if (opts.audit) meta.audit = opts.audit
return meta
}
/**
* 200 OK with `{ data, meta }`.
*/
export function ok<T>(data: T, opts: ResponseOptions): NextResponse {
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: opts.status ?? 200 })
return applyStandardHeaders(res, opts)
}
/**
* 200 OK with `{ data: T[], meta: { next_cursor } }`. Use for list endpoints.
*/
export function paginated<T>(data: T[], opts: ResponseOptions): NextResponse {
const res = NextResponse.json({ data, meta: buildMeta(opts) }, { status: 200 })
return applyStandardHeaders(res, opts)
}
/**
* 201 Created. Mirror of ok() with status 201 for POST that creates a resource.
*/
export function created<T>(data: T, opts: ResponseOptions): NextResponse {
return ok(data, { ...opts, status: 201 })
}
/**
* 202 Accepted for async long-running operations. Returns the operation_id +
* polling URL + the webhook event the caller can subscribe to for completion.
*/
export function accepted(
operationId: string,
operationType: string,
opts: ResponseOptions,
): NextResponse {
const data = {
operation_id: operationId,
type: operationType,
status: 'queued' as const,
poll_url: `/api/v1/operations/${operationId}`,
webhook_event: 'operation.completed',
}
return ok(data, { ...opts, status: 202 })
}
/**
* 204 No Content. Used for DELETE responses. No body.
*/
export function noContent(opts: ResponseOptions): NextResponse {
const res = new NextResponse(null, { status: 204 })
return applyStandardHeaders(res, opts)
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Common security headers for public v1 responses.
*
* Applied to discovery routes (`/llms.txt`, `/.well-known/skills/index.json`,
* `/api/v1/openapi.json`) that bypass the auth wrapper.
*
* The wrapped routes don't need these explicitly: NextResponse's defaults +
* the auth wrapper's stamping cover them. Public routes are an exception
* because they're plain `NextResponse.json/text` returns with caching.
*
* X-Content-Type-Options: nosniff — block MIME sniffing on text/json
* Referrer-Policy: strict-origin... — limit referrer leakage if a link is
* embedded somewhere unexpected
* X-Frame-Options: DENY — discovery surfaces should never
* legitimately render in a frame
*/
/**
* Headers applied to BOTH public discovery routes and authenticated v1
* responses. Includes CSP, HSTS, and frame/sniff/referrer protections, but
* NOT X-Robots-Tag — discovery routes (llms.txt, skills index, OpenAPI)
* exist to be crawled by AI agents; authenticated routes get an additional
* X-Robots-Tag at the wrapper level via WRAPPED_RESPONSE_NOAI_HEADERS.
*/
export const PUBLIC_SECURITY_HEADERS: Record<string, string> = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Frame-Options': 'DENY',
// Discovery routes return JSON or plain text — no script, style, image, or
// form contexts. `default-src 'none'` is the strictest possible CSP and
// costs nothing here.
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
// HSTS — every gnubok deployment is HTTPS-only. 1 year is the standard
// production value; includeSubDomains because the apex serves everything.
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}
/**
* Additional headers applied ONLY to authenticated v1 responses. AI bots
* that respect X-Robots-Tag (Claude, ChatGPT, Perplexity, Google-Extended)
* will skip these payloads for training; others will ignore the hint.
* Public discovery routes deliberately omit this so they remain
* AI-discoverable.
*/
export const WRAPPED_RESPONSE_HEADERS: Record<string, string> = {
...PUBLIC_SECURITY_HEADERS,
'X-Robots-Tag': 'noai, noimageai',
}
/**
* Merge the public security headers onto an arbitrary header dict so callers
* can keep their own Content-Type / Cache-Control entries.
*/
export function withPublicSecurityHeaders(extra: Record<string, string> = {}): Record<string, string> {
return { ...PUBLIC_SECURITY_HEADERS, ...extra }
}
+12
View File
@@ -0,0 +1,12 @@
/**
* The current API version date.
*
* Echoed in every response's `meta.api_version` and accepted as the optional
* `Gnubok-Version` request header for future date-pinned breaking changes.
*
* Bump this when shipping a breaking change inside v1; older versions are
* preserved as long as integrators pin to them via the header.
*/
export const API_V1_VERSION = '2026-05-12'
export const API_V1_VERSION_HEADER = 'Gnubok-Version'
+451
View File
@@ -0,0 +1,451 @@
/**
* 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 to consume the body to hash it, but the handler also needs it.
// Clone the request first so the handler can re-read.
const cloned = request.clone()
const text = await request.text()
if (!text) return { body: null, cloned }
try {
return { body: JSON.parse(text), cloned }
} catch {
return { body: text, cloned }
}
}
/**
* 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
}
+31
View File
@@ -218,7 +218,10 @@ describe('validateApiKey', () => {
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: undefined,
apiKeyName: undefined,
scopes: ['transactions:read', 'reports:read'],
mode: 'live',
})
})
@@ -237,7 +240,35 @@ describe('validateApiKey', () => {
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: undefined,
apiKeyName: undefined,
scopes: DEFAULT_SCOPES,
mode: 'live',
})
})
it('surfaces mode from the RPC row', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
api_key_id: 'ak_1',
api_key_name: 'CI test key',
scopes: ['transactions:read'],
rate_limited: false,
mode: 'test',
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
apiKeyId: 'ak_1',
apiKeyName: 'CI test key',
scopes: ['transactions:read'],
mode: 'test',
})
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { resolveRequiredScope } from '../scopes'
describe('resolveRequiredScope', () => {
it('returns public for the health endpoint', () => {
expect(resolveRequiredScope('GET', '/api/v1/health')).toBe('public')
})
it('returns public for openapi.json', () => {
expect(resolveRequiredScope('GET', '/api/v1/openapi.json')).toBe('public')
})
it('resolves the companies:read scope for GET /api/v1/companies', () => {
expect(resolveRequiredScope('GET', '/api/v1/companies')).toBe('companies:read')
})
it('resolves :param patterns to a single scope', () => {
expect(
resolveRequiredScope('GET', '/api/v1/companies/8fd5b1f4-1111-2222-3333-444455556666'),
).toBe('companies:read')
})
it('returns null for unknown paths', () => {
expect(resolveRequiredScope('GET', '/api/v1/non-existent')).toBeNull()
})
it('does not match the wrong HTTP method', () => {
expect(resolveRequiredScope('DELETE', '/api/v1/companies')).toBeNull()
})
it('does not let :param greedily consume slashes', () => {
// /companies/{companyId} should not match /companies/abc/extra
expect(resolveRequiredScope('GET', '/api/v1/companies/abc/extra')).toBeNull()
})
})
+20
View File
@@ -19,6 +19,14 @@ export const API_KEY_SCOPES = {
'bookkeeping:write': { label: 'Bokföring — skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
// v1 REST API — added Phase 1
'companies:read': { label: 'Företag — läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
'events:read': { label: 'Händelser — läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
'webhooks:manage': { label: 'Webhooks — hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
'operations:read': { label: 'Operationer — läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' },
'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet' },
} as const
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
@@ -189,6 +197,13 @@ export function extractBearerToken(request: Request): string | null {
* They may be undefined when the deployed DB hasn't yet run the migration
* that adds them to the RPC return shape.
*/
/**
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
* are bound to deterministic sandbox companies. Keys created before the Phase 1
* migration default to 'live' for backwards compatibility.
*/
export type ApiKeyMode = 'live' | 'test'
export async function validateApiKey(
key: string
): Promise<
@@ -198,6 +213,7 @@ export async function validateApiKey(
apiKeyId?: string
apiKeyName?: string
scopes: ApiKeyScope[]
mode: ApiKeyMode
}
| { error: string; status: number }
> {
@@ -235,6 +251,10 @@ export async function validateApiKey(
apiKeyId: row.api_key_id,
apiKeyName: row.api_key_name,
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
// migration that adds it to the RPC return. Default to 'live' so existing
// keys behave unchanged.
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
}
}
+97
View File
@@ -0,0 +1,97 @@
/**
* v1 REST API endpoint → required scope map.
*
* This is the REST-route analogue of `TOOL_SCOPE_MAP` in api-keys.ts (which
* maps MCP tool names to scopes). Both share the same `ApiKeyScope` registry.
*
* Key format: `<METHOD> <pattern>` where pattern uses `:param` for path
* variables, matching Next.js dynamic-segment conventions (one for one).
*
* Endpoints not listed here are public (no auth) — only the discovery routes
* (`/llms.txt`, `/.well-known/skills`, `/api/v1/health`, `/api/v1/openapi.json`)
* fall into that bucket. Everything else under `/api/v1/` MUST be in this map
* or the wrapper will refuse the request with INSUFFICIENT_SCOPE.
*/
import type { ApiKeyScope } from './api-keys'
/**
* Routes that require authentication but no scope check beyond "is the key
* valid?". The wrapper still validates the key and runs rate limiting.
*/
export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
'GET /api/v1/health',
'GET /api/v1/openapi.json',
'GET /api/v1/openapi.yaml',
]
/**
* Map of v1 endpoint pattern → required scope.
*
* Patterns use `:param` placeholders that match a single path segment.
* The wrapper compiles these into regexes at startup and matches incoming
* requests by (method, normalized-path) tuple.
*
* When adding a new endpoint, add it here BEFORE shipping the route file —
* otherwise the wrapper will reject all requests to it.
*/
export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// Companies
'GET /api/v1/companies': 'companies:read',
'GET /api/v1/companies/:companyId': 'companies:read',
// Operations (async long-running tasks)
'GET /api/v1/operations/:id': 'operations:read',
// Events (webhook fallback / event log polling)
'GET /api/v1/companies/:companyId/events': 'events:read',
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
'GET /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
'PATCH /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
'DELETE /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
}
interface CompiledRoute {
method: string
regex: RegExp
scope: ApiKeyScope
}
let compiledCache: CompiledRoute[] | null = null
function compileAll(): CompiledRoute[] {
if (compiledCache) return compiledCache
compiledCache = Object.entries(V1_ENDPOINT_SCOPES).map(([pattern, scope]) => {
const [method, path] = pattern.split(' ', 2)
const regexStr = '^' + path.replace(/:[^/]+/g, '[^/]+') + '$'
return { method, regex: new RegExp(regexStr), scope }
})
return compiledCache
}
/**
* Resolve the required scope for a given (method, path) request.
*
* - Returns the scope when a registered v1 endpoint matches.
* - Returns 'public' for paths in V1_PUBLIC_ENDPOINTS (no scope check needed,
* but the wrapper may still want to log the key id).
* - Returns null when the path is unknown — the wrapper should treat this as
* a 404 NOT_FOUND rather than letting the request through unauthenticated.
*/
export function resolveRequiredScope(method: string, path: string): ApiKeyScope | 'public' | null {
const key = `${method} ${path}`
if (V1_PUBLIC_ENDPOINTS.includes(key)) return 'public'
const compiled = compileAll()
for (const route of compiled) {
if (route.method === method && route.regex.test(path)) {
return route.scope
}
}
return null
}
@@ -0,0 +1,83 @@
-- =============================================================================
-- Phase 1: Public REST API v1 — test/live mode for API keys
-- =============================================================================
-- Adds `mode` to api_keys so a single user can own both `gnubok_sk_live_*` and
-- `gnubok_sk_test_*` keys. Test keys are intended to be bound to deterministic
-- sandbox companies in a later commit; this migration only adds the column and
-- surfaces it through the validate_and_increment_api_key RPC so the wrapper
-- can branch on it.
--
-- Existing rows are backfilled to 'live' to preserve current behaviour.
-- =============================================================================
-- 1. Column + check + index
ALTER TABLE public.api_keys
ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'live'
CHECK (mode IN ('live', 'test'));
CREATE INDEX IF NOT EXISTS idx_api_keys_mode ON public.api_keys (mode);
-- 2. RPC: surface `mode` in the return type
-- (CREATE OR REPLACE cannot change return types in PostgreSQL — drop first.)
DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text);
CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
RETURNS TABLE(
user_id uuid,
company_id uuid,
api_key_id uuid,
api_key_name text,
rate_limited boolean,
scopes text[],
mode text
)
LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
v_api_key_id uuid;
v_api_key_name text;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
v_scopes text[];
v_mode text;
BEGIN
SELECT ak.user_id, ak.company_id, ak.id, ak.name,
ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes, ak.mode
INTO v_user_id, v_company_id, v_api_key_id, v_api_key_name,
v_rate_limit_rpm, v_request_count, v_window_start, v_scopes, v_mode
FROM public.api_keys ak
WHERE ak.key_hash = p_key_hash AND ak.revoked_at IS NULL
FOR UPDATE;
IF v_user_id IS NULL THEN
RETURN;
END IF;
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.api_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes, v_mode;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, true, v_scopes, v_mode;
RETURN;
END IF;
UPDATE public.api_keys
SET request_count = request_count + 1,
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes, v_mode;
END;
$$;
NOTIFY pgrst, 'reload schema';