diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 0e41c940..01cad102 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -37,6 +37,7 @@ function RegisterPageContent() { const [confirmPassword, setConfirmPassword] = useState('') const [isLoading, setIsLoading] = useState(false) const [isRegistered, setIsRegistered] = useState(false) + const [duplicateEmail, setDuplicateEmail] = useState(null) const [inviteEmail, setInviteEmail] = useState(null) const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null) const [bankIdSessionId, setBankIdSessionId] = useState(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 ( +
+
+
+
+ +
+
+ +
+

Kontot finns redan

+

+ Det finns redan ett konto kopplat till{' '} + {duplicateEmail}. +

+
+ +
+

+ Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via "Glömt lösenord?" på inloggningssidan. +

+
+ +
+ + +
+
+
+ ) + } + if (isRegistered) { return (
diff --git a/app/.well-known/skills/index.json/route.ts b/app/.well-known/skills/index.json/route.ts new file mode 100644 index 00000000..99a01d60 --- /dev/null +++ b/app/.well-known/skills/index.json/route.ts @@ -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', + }), + }) +} diff --git a/app/api/v1/companies/route.ts b/app/api/v1/companies/route.ts new file mode 100644 index 00000000..35e70134 --- /dev/null +++ b/app/api/v1/companies/route.ts @@ -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 => 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, + }) +}) diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts new file mode 100644 index 00000000..034687f5 --- /dev/null +++ b/app/api/v1/health/route.ts @@ -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 }, + ) +}) diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts new file mode 100644 index 00000000..1c370d96 --- /dev/null +++ b/app/api/v1/openapi.json/route.ts @@ -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', + }), + }) +} diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts new file mode 100644 index 00000000..477a4b68 --- /dev/null +++ b/app/llms.txt/route.ts @@ -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__\`. +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', + }), + }) +} diff --git a/lib/api/v1/__tests__/pagination.test.ts b/lib/api/v1/__tests__/pagination.test.ts new file mode 100644 index 00000000..0a9d0bee --- /dev/null +++ b/lib/api/v1/__tests__/pagination.test.ts @@ -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 }) + }) +}) diff --git a/lib/api/v1/__tests__/response.test.ts b/lib/api/v1/__tests__/response.test.ts new file mode 100644 index 00000000..c39a7230 --- /dev/null +++ b/lib/api/v1/__tests__/response.test.ts @@ -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', + }) + }) +}) diff --git a/lib/api/v1/__tests__/with-api-v1.test.ts b/lib/api/v1/__tests__/with-api-v1.test.ts new file mode 100644 index 00000000..83af45be --- /dev/null +++ b/lib/api/v1/__tests__/with-api-v1.test.ts @@ -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('@/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('@supabase/supabase-js') + return { + ...actual, + createClient: vi.fn().mockReturnValue({}), + } +}) + +vi.mock('@/lib/api/idempotency', async () => { + const actual = await vi.importActual( + '@/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 +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockCheckIdempotency = checkIdempotencyKey as ReturnType +const mockStoreIdempotency = storeIdempotencyResponse as ReturnType + +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 diff --git a/lib/api/v1/base-url.ts b/lib/api/v1/base-url.ts new file mode 100644 index 00000000..cce3abcc --- /dev/null +++ b/lib/api/v1/base-url.ts @@ -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' +} diff --git a/lib/api/v1/errors.ts b/lib/api/v1/errors.ts new file mode 100644 index 00000000..df8313f7 --- /dev/null +++ b/lib/api/v1/errors.ts @@ -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 + 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 +} + +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 { + 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 { + 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 { + 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 +} diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts new file mode 100644 index 00000000..4b13873b --- /dev/null +++ b/lib/api/v1/load-routes.ts @@ -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 {} diff --git a/lib/api/v1/pagination.ts b/lib/api/v1/pagination.ts new file mode 100644 index 00000000..5069c03a --- /dev/null +++ b/lib/api/v1/pagination.ts @@ -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 + 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( + rows: T[], + limit: number, +): string | null { + if (rows.length <= limit) return null + return encodeDefaultCursor(rows[limit]) +} diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts new file mode 100644 index 00000000..121bf6bf --- /dev/null +++ b/lib/api/v1/registry.ts @@ -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 + response: Record + } + + /** 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() + +/** + * 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 + 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 }).shape + const properties: Record = {} + 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 } + 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 } + security: Array> + paths: Record> +} + +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 = { + 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__', + }, + }, + }, + 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() +} diff --git a/lib/api/v1/response.ts b/lib/api/v1/response.ts new file mode 100644 index 00000000..f41ce1ca --- /dev/null +++ b/lib/api/v1/response.ts @@ -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 + 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(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(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(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) +} diff --git a/lib/api/v1/security-headers.ts b/lib/api/v1/security-headers.ts new file mode 100644 index 00000000..798613f3 --- /dev/null +++ b/lib/api/v1/security-headers.ts @@ -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 = { + '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 = { + ...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 = {}): Record { + return { ...PUBLIC_SECURITY_HEADERS, ...extra } +} diff --git a/lib/api/v1/version.ts b/lib/api/v1/version.ts new file mode 100644 index 00000000..46d873ad --- /dev/null +++ b/lib/api/v1/version.ts @@ -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' diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts new file mode 100644 index 00000000..b0a644db --- /dev/null +++ b/lib/api/v1/with-api-v1.ts @@ -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_`) 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> } | { params: Promise<{}> } + +type V1Handler

> }> = ( + request: Request, + ctx: ApiV1Context, + params: P, +) => Promise + +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

> }>( + operation: string, + handler: V1Handler

, + options: ApiV1Options = {}, +): (request: Request, params: P) => Promise { + return async function wrapped(request: Request, params: P): Promise { + 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 + 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, + '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 +} diff --git a/lib/auth/__tests__/api-keys.test.ts b/lib/auth/__tests__/api-keys.test.ts index 2a21a1c3..e2be0c77 100644 --- a/lib/auth/__tests__/api-keys.test.ts +++ b/lib/auth/__tests__/api-keys.test.ts @@ -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', }) }) }) diff --git a/lib/auth/__tests__/scopes.test.ts b/lib/auth/__tests__/scopes.test.ts new file mode 100644 index 00000000..a2c56fa7 --- /dev/null +++ b/lib/auth/__tests__/scopes.test.ts @@ -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() + }) +}) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index be22e401..784ee4a7 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -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, } } diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts new file mode 100644 index 00000000..dfb6803c --- /dev/null +++ b/lib/auth/scopes.ts @@ -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: ` ` 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 = [ + '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 = { + // 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 +} diff --git a/supabase/migrations/20260512162506_api_v1_test_mode.sql b/supabase/migrations/20260512162506_api_v1_test_mode.sql new file mode 100644 index 00000000..b05e5948 --- /dev/null +++ b/supabase/migrations/20260512162506_api_v1_test_mode.sql @@ -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';