diff --git a/app/docs/api.md/route.ts b/app/docs/api.md/route.ts new file mode 100644 index 00000000..2475c8d5 --- /dev/null +++ b/app/docs/api.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { LANDING_MD } from '@/lib/docs/content/landing' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(LANDING_MD, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/changelog.md/route.ts b/app/docs/api/changelog.md/route.ts new file mode 100644 index 00000000..f4f5f502 --- /dev/null +++ b/app/docs/api/changelog.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { CHANGELOG_MD } from '@/lib/docs/content/changelog' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(CHANGELOG_MD, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/changelog/page.tsx b/app/docs/api/changelog/page.tsx new file mode 100644 index 00000000..9510c14a --- /dev/null +++ b/app/docs/api/changelog/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { CHANGELOG_MD } from '@/lib/docs/content/changelog' + +export const metadata: Metadata = { + title: 'Changelog · gnubok API', + description: 'Reverse-chronological release notes for the gnubok REST API.', +} + +export default function DocsApiChangelogPage() { + return ( + + + + ) +} diff --git a/app/docs/api/cookbook/[slug].md/route.ts b/app/docs/api/cookbook/[slug].md/route.ts new file mode 100644 index 00000000..f5fce676 --- /dev/null +++ b/app/docs/api/cookbook/[slug].md/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { findRecipe, buildPlaceholderMd, COOKBOOK_SLUGS } from '@/lib/docs/content/cookbook' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +// Pre-validate the slug against the closed allow-list before any lookup +// runs. Defense-in-depth (V1.2.5): findRecipe is dictionary-based so the +// raw value can't reach SQL or filesystem code paths, but if the lookup +// is ever swapped (e.g. dynamic file load) the explicit allow-list +// gate keeps the contract safe by construction. +const SLUG_ALLOW = new Set(COOKBOOK_SLUGS) + +// Next.js 16 does not extract the dynamic segment name from a directory +// like `[slug].md/` — the literal `.md` suffix breaks the inference and +// the framework types `params` as `{}`. Workaround: parse the slug from +// request.url.pathname directly. Routing still works (Next.js still +// matches /docs/api/cookbook/foo.md to this handler) — only the +// `params` typing is unusable. +export async function GET(request: Request) { + const url = new URL(request.url) + const match = url.pathname.match(/\/cookbook\/([^/]+)\.md$/) + const raw = match?.[1] + // URL-decode before the allow-list check so a percent-encoded slug + // can't slip through the literal Set lookup. The allow-list is pure + // ASCII so any decoded value matching means the caller could have + // requested the canonical slug directly — no behaviour change for + // legitimate clients, defense-in-depth for adversarial ones. + let slug: string | undefined + try { + slug = raw ? decodeURIComponent(raw) : undefined + } catch { + return new NextResponse('Not found', { status: 404 }) + } + if (!slug || !SLUG_ALLOW.has(slug)) { + return new NextResponse('Not found', { status: 404 }) + } + + const entry = findRecipe(slug) + if (!entry) return new NextResponse('Not found', { status: 404 }) + + const md = entry.markdown ?? buildPlaceholderMd(entry) + return new NextResponse(md, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/cookbook/[slug]/page.tsx b/app/docs/api/cookbook/[slug]/page.tsx new file mode 100644 index 00000000..cf07cad9 --- /dev/null +++ b/app/docs/api/cookbook/[slug]/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { findRecipe, COOKBOOK_SLUGS, buildPlaceholderMd } from '@/lib/docs/content/cookbook' + +export function generateStaticParams() { + return COOKBOOK_SLUGS.map((slug) => ({ slug })) +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params + const entry = findRecipe(slug) + if (!entry) return { title: 'Not found' } + return { + title: `${entry.title} · gnubok API cookbook`, + description: entry.description, + } +} + +export default async function DocsApiCookbookRecipePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const entry = findRecipe(slug) + if (!entry) notFound() + const md = entry.markdown ?? buildPlaceholderMd(entry) + return ( + + + + ) +} diff --git a/app/docs/api/errors.md/route.ts b/app/docs/api/errors.md/route.ts new file mode 100644 index 00000000..d6fade01 --- /dev/null +++ b/app/docs/api/errors.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { buildErrorReferenceMd } from '@/lib/docs/content/errors' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(buildErrorReferenceMd(), { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/errors/page.tsx b/app/docs/api/errors/page.tsx new file mode 100644 index 00000000..31e0061c --- /dev/null +++ b/app/docs/api/errors/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { buildErrorReferenceMd } from '@/lib/docs/content/errors' + +export const metadata: Metadata = { + title: 'Errors · gnubok API', + description: 'Every stable error code returned by the gnubok REST API, with HTTP status, description, and remediation.', +} + +export default function DocsApiErrorsPage() { + const md = buildErrorReferenceMd() + return ( + + + + ) +} diff --git a/app/docs/api/page.tsx b/app/docs/api/page.tsx new file mode 100644 index 00000000..c6d9ac1b --- /dev/null +++ b/app/docs/api/page.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { DOCS_NAV } from '@/lib/docs/nav' +import { LANDING_MD } from '@/lib/docs/content/landing' +import Link from 'next/link' + +export const metadata: Metadata = { + title: 'gnubok API · Documentation', + description: 'Swedish double-entry bookkeeping as a public REST API for agents and integrations.', +} + +export default function DocsApiLandingPage() { + // Highlight the cookbook + reference grids on the landing page itself, + // Stripe-style cards. The markdown body provides the prose context; + // the cards below give visual scannability. + const cookbooks = DOCS_NAV.find((s) => s.label === 'Cookbooks')?.links ?? [] + const reference = DOCS_NAV.find((s) => s.label === 'API reference')?.links?.slice(0, 8) ?? [] + + return ( + + + +
+

Cookbooks

+

+ End-to-end recipes for the most common integrations. Copy-paste ready, tested against the sandbox. +

+
+ {cookbooks.map((c) => ( + +
{c.label}
+ {c.summary && ( +
{c.summary}
+ )} + + ))} +
+
+ +
+

API reference

+

+ Every endpoint, grouped by resource. Auto-generated from the same Zod registry that powers the OpenAPI spec, MCP tools, and runtime validators. +

+
+ {reference.map((r) => ( + + {r.label} + + ))} + + See all → + +
+
+
+ ) +} diff --git a/app/docs/api/reference.md/route.ts b/app/docs/api/reference.md/route.ts new file mode 100644 index 00000000..5ee6fadf --- /dev/null +++ b/app/docs/api/reference.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { buildReferenceOverviewMd } from '@/lib/docs/content/reference' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(buildReferenceOverviewMd(), { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/reference/[slug].md/route.ts b/app/docs/api/reference/[slug].md/route.ts new file mode 100644 index 00000000..141ea5fd --- /dev/null +++ b/app/docs/api/reference/[slug].md/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' +import { buildResourcePages, RESOURCE_SLUGS } from '@/lib/docs/content/reference' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +// Pre-validate the slug against the closed allow-list before any lookup +// runs. Defense-in-depth (V1.2.5) — the lookup is array-find on a +// memoised in-process collection so a bad slug couldn't escape into a +// SQL or filesystem path, but the explicit allow-list gate keeps the +// contract safe by construction if the lookup mechanism ever changes. +const SLUG_ALLOW = new Set(RESOURCE_SLUGS) + +// Next.js 16 does not extract the dynamic segment name from a directory +// like `[slug].md/` — the literal `.md` suffix breaks the inference and +// the framework types `params` as `{}`. Workaround: parse the slug from +// request.url.pathname directly. Routing still works (Next.js still +// matches /docs/api/reference/foo.md to this handler) — only the +// `params` typing is unusable. +export async function GET(request: Request) { + const url = new URL(request.url) + const match = url.pathname.match(/\/reference\/([^/]+)\.md$/) + const raw = match?.[1] + // URL-decode before the allow-list check so a percent-encoded slug + // can't slip through the literal Set lookup. Same pattern as the + // cookbook .md route handler. + let slug: string | undefined + try { + slug = raw ? decodeURIComponent(raw) : undefined + } catch { + return new NextResponse('Not found', { status: 404 }) + } + if (!slug || !SLUG_ALLOW.has(slug)) { + return new NextResponse('Not found', { status: 404 }) + } + + const page = buildResourcePages().find((p) => p.slug === slug) + if (!page) return new NextResponse('Not found', { status: 404 }) + + return new NextResponse(page.markdown, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/reference/[slug]/page.tsx b/app/docs/api/reference/[slug]/page.tsx new file mode 100644 index 00000000..d9af6baa --- /dev/null +++ b/app/docs/api/reference/[slug]/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { buildResourcePages, RESOURCE_SLUGS } from '@/lib/docs/content/reference' + +export function generateStaticParams() { + return RESOURCE_SLUGS.map((slug) => ({ slug })) +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params + const page = buildResourcePages().find((p) => p.slug === slug) + if (!page) return { title: 'Not found' } + return { + title: `${page.label} · gnubok API`, + description: page.description, + } +} + +export default async function DocsApiReferenceResourcePage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const page = buildResourcePages().find((p) => p.slug === slug) + if (!page) notFound() + + return ( + + + + ) +} diff --git a/app/docs/api/reference/page.tsx b/app/docs/api/reference/page.tsx new file mode 100644 index 00000000..54ab6297 --- /dev/null +++ b/app/docs/api/reference/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { buildReferenceOverviewMd } from '@/lib/docs/content/reference' + +export const metadata: Metadata = { + title: 'API reference · gnubok API', + description: 'Every endpoint exposed by the gnubok REST API, grouped by resource.', +} + +export default function DocsApiReferencePage() { + return ( + + + + ) +} diff --git a/app/docs/api/versioning.md/route.ts b/app/docs/api/versioning.md/route.ts new file mode 100644 index 00000000..df5db588 --- /dev/null +++ b/app/docs/api/versioning.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { VERSIONING_MD } from '@/lib/docs/content/versioning' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(VERSIONING_MD, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/versioning/page.tsx b/app/docs/api/versioning/page.tsx new file mode 100644 index 00000000..618f383d --- /dev/null +++ b/app/docs/api/versioning/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { VERSIONING_MD } from '@/lib/docs/content/versioning' + +export const metadata: Metadata = { + title: 'Versioning · gnubok API', + description: 'How API versions are pinned, upgraded, and deprecated. Plus idempotency, dry-run, and strict-mode write semantics.', +} + +export default function DocsApiVersioningPage() { + return ( + + + + ) +} diff --git a/app/docs/api/webhooks.md/route.ts b/app/docs/api/webhooks.md/route.ts new file mode 100644 index 00000000..5eabe303 --- /dev/null +++ b/app/docs/api/webhooks.md/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import { WEBHOOKS_MD } from '@/lib/docs/content/webhooks' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +export async function GET() { + return new NextResponse(WEBHOOKS_MD, { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/app/docs/api/webhooks/page.tsx b/app/docs/api/webhooks/page.tsx new file mode 100644 index 00000000..7ec047bd --- /dev/null +++ b/app/docs/api/webhooks/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next' +import { DocsLayout } from '@/components/docs/DocsLayout' +import { DocsMarkdown } from '@/lib/docs/markdown' +import { WEBHOOKS_MD } from '@/lib/docs/content/webhooks' + +export const metadata: Metadata = { + title: 'Webhooks · gnubok API', + description: 'Receive HMAC-signed POST notifications when state changes in gnubok. Includes signature verification samples in Node.js and Python.', +} + +export default function DocsApiWebhooksPage() { + return ( + + + + ) +} diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts new file mode 100644 index 00000000..525b5e4b --- /dev/null +++ b/app/llms-full.txt/route.ts @@ -0,0 +1,59 @@ +/** + * /llms-full.txt — full docs concatenated for LLM ingestion. + * + * Sibling to /llms.txt (which is the concise discovery index). This is the + * "ingest the whole thing in one HTTP call" surface — agents that need + * deep context can fetch this once and parse instead of crawling every + * /docs/api/*.md page individually. + * + * Concatenates: landing → versioning → webhooks concept → errors → + * reference overview → every per-resource reference → quickstart cookbook + * → webhooks cookbook → changelog. Section separators are `---` so a + * downstream Markdown parser sees them as horizontal rules. + */ + +import { NextResponse } from 'next/server' +import { LANDING_MD } from '@/lib/docs/content/landing' +import { VERSIONING_MD } from '@/lib/docs/content/versioning' +import { WEBHOOKS_MD } from '@/lib/docs/content/webhooks' +import { CHANGELOG_MD } from '@/lib/docs/content/changelog' +import { buildErrorReferenceMd } from '@/lib/docs/content/errors' +import { buildReferenceOverviewMd, buildResourcePages } from '@/lib/docs/content/reference' +import { COOKBOOK } from '@/lib/docs/content/cookbook' +import { withPublicSecurityHeaders } from '@/lib/api/v1/security-headers' + +function joinSections(sections: string[]): string { + return sections.map((s) => s.trim()).join('\n\n---\n\n') +} + +function build(): string { + const sections: string[] = [ + LANDING_MD, + VERSIONING_MD, + WEBHOOKS_MD, + buildErrorReferenceMd(), + buildReferenceOverviewMd(), + ] + + for (const page of buildResourcePages()) { + sections.push(page.markdown) + } + + for (const recipe of COOKBOOK) { + if (recipe.markdown) sections.push(recipe.markdown) + } + + sections.push(CHANGELOG_MD) + + return joinSections(sections) +} + +export async function GET() { + return new NextResponse(build(), { + status: 200, + headers: withPublicSecurityHeaders({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300, s-maxage=300', + }), + }) +} diff --git a/components/docs/DocsLayout.tsx b/components/docs/DocsLayout.tsx new file mode 100644 index 00000000..eb38b242 --- /dev/null +++ b/components/docs/DocsLayout.tsx @@ -0,0 +1,110 @@ +/** + * Stripe-inspired two-column docs layout. + * + * ┌────────────────┬──────────────────────────────────────────┐ + * │ │ │ + * │ Sidebar │ Main content (max-w-4xl) │ + * │ (sticky, │ │ + * │ grouped) │ │ + * │ │ │ + * └────────────────┴──────────────────────────────────────────┘ + * + * Aesthetic: paper-white surfaces, hairline borders, Hedvig display + * headlines, Geist body. Sidebar entries use the same warm-beige hover + * + active background as the dashboard sidebar so the docs feel like the + * same instrument as the app — not a separate marketing site. + */ + +import Link from 'next/link' +import { DOCS_NAV } from '@/lib/docs/nav' +import { cn } from '@/lib/utils' + +interface DocsLayoutProps { + /** The pathname of the current page so the sidebar can highlight it. */ + currentPath: string + children: React.ReactNode +} + +export function DocsLayout({ currentPath, children }: DocsLayoutProps) { + return ( +
+
+
+ + gnubok / docs + + +
+
+ +
+ + +
+ {children} +
+
+ +
+
+ gnubok REST API · Swedish bookkeeping for agents + AGPL-3.0-or-later +
+
+
+ ) +} + +function stripFragment(href: string): string { + const hashIdx = href.indexOf('#') + return hashIdx === -1 ? href : href.slice(0, hashIdx) +} diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap new file mode 100644 index 00000000..c4717279 --- /dev/null +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -0,0 +1,132 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `100`; + +exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` +[ + "DELETE /api/v1/companies/:companyId/customers/:id", + "DELETE /api/v1/companies/:companyId/employees/:id", + "DELETE /api/v1/companies/:companyId/salary-runs/:id", + "DELETE /api/v1/companies/:companyId/suppliers/:id", + "DELETE /api/v1/companies/:companyId/webhooks/:id", + "GET /api/v1/companies", + "GET /api/v1/companies/:companyId/accounts", + "GET /api/v1/companies/:companyId/compliance/check", + "GET /api/v1/companies/:companyId/customers", + "GET /api/v1/companies/:companyId/customers/:id", + "GET /api/v1/companies/:companyId/documents/:id/download", + "GET /api/v1/companies/:companyId/employees", + "GET /api/v1/companies/:companyId/employees/:id", + "GET /api/v1/companies/:companyId/fiscal-periods", + "GET /api/v1/companies/:companyId/invoices", + "GET /api/v1/companies/:companyId/invoices/:id", + "GET /api/v1/companies/:companyId/invoices/:id/pdf", + "GET /api/v1/companies/:companyId/journal-entries", + "GET /api/v1/companies/:companyId/journal-entries/:id", + "GET /api/v1/companies/:companyId/reconciliation/bank/status", + "GET /api/v1/companies/:companyId/reports/ar-ledger", + "GET /api/v1/companies/:companyId/reports/avgifter-basis", + "GET /api/v1/companies/:companyId/reports/balance-sheet", + "GET /api/v1/companies/:companyId/reports/continuity-check", + "GET /api/v1/companies/:companyId/reports/general-ledger", + "GET /api/v1/companies/:companyId/reports/income-statement", + "GET /api/v1/companies/:companyId/reports/journal-register", + "GET /api/v1/companies/:companyId/reports/monthly-breakdown", + "GET /api/v1/companies/:companyId/reports/salary-journal", + "GET /api/v1/companies/:companyId/reports/sie-export", + "GET /api/v1/companies/:companyId/reports/supplier-ledger", + "GET /api/v1/companies/:companyId/reports/trial-balance", + "GET /api/v1/companies/:companyId/reports/vacation-liability", + "GET /api/v1/companies/:companyId/reports/vat-declaration", + "GET /api/v1/companies/:companyId/salary-runs", + "GET /api/v1/companies/:companyId/salary-runs/:id", + "GET /api/v1/companies/:companyId/supplier-invoices", + "GET /api/v1/companies/:companyId/supplier-invoices/:id", + "GET /api/v1/companies/:companyId/suppliers", + "GET /api/v1/companies/:companyId/suppliers/:id", + "GET /api/v1/companies/:companyId/transactions", + "GET /api/v1/companies/:companyId/transactions/:id", + "GET /api/v1/companies/:companyId/webhooks", + "GET /api/v1/companies/:companyId/webhooks/:id", + "GET /api/v1/companies/:companyId/webhooks/:id/deliveries", + "GET /api/v1/health", + "GET /api/v1/operations/:id", + "PATCH /api/v1/companies/:companyId/customers/:id", + "PATCH /api/v1/companies/:companyId/employees/:id", + "PATCH /api/v1/companies/:companyId/invoices/:id", + "PATCH /api/v1/companies/:companyId/salary-runs/:id", + "PATCH /api/v1/companies/:companyId/supplier-invoices/:id", + "PATCH /api/v1/companies/:companyId/suppliers/:id", + "PATCH /api/v1/companies/:companyId/webhooks/:id", + "POST /api/v1/companies/:companyId/customers", + "POST /api/v1/companies/:companyId/customers/bulk-create", + "POST /api/v1/companies/:companyId/documents", + "POST /api/v1/companies/:companyId/documents/:id/link", + "POST /api/v1/companies/:companyId/employees", + "POST /api/v1/companies/:companyId/fiscal-periods/:id/close", + "POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation", + "POST /api/v1/companies/:companyId/fiscal-periods/:id/lock", + "POST /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances", + "POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end", + "POST /api/v1/companies/:companyId/imports/bank", + "POST /api/v1/companies/:companyId/imports/sie", + "POST /api/v1/companies/:companyId/invoices", + "POST /api/v1/companies/:companyId/invoices/:id/credit", + "POST /api/v1/companies/:companyId/invoices/:id/mark-paid", + "POST /api/v1/companies/:companyId/invoices/:id/mark-sent", + "POST /api/v1/companies/:companyId/invoices/:id/send", + "POST /api/v1/companies/:companyId/invoices/bulk-create", + "POST /api/v1/companies/:companyId/journal-entries", + "POST /api/v1/companies/:companyId/journal-entries/:id/commit", + "POST /api/v1/companies/:companyId/journal-entries/:id/correct", + "POST /api/v1/companies/:companyId/journal-entries/:id/reverse", + "POST /api/v1/companies/:companyId/journal-entries/batch-create", + "POST /api/v1/companies/:companyId/reconciliation/bank/run", + "POST /api/v1/companies/:companyId/salary-runs", + "POST /api/v1/companies/:companyId/salary-runs/:id/approve", + "POST /api/v1/companies/:companyId/salary-runs/:id/book", + "POST /api/v1/companies/:companyId/salary-runs/:id/calculate", + "POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi", + "POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid", + "POST /api/v1/companies/:companyId/supplier-invoices", + "POST /api/v1/companies/:companyId/supplier-invoices/:id/approve", + "POST /api/v1/companies/:companyId/supplier-invoices/:id/credit", + "POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid", + "POST /api/v1/companies/:companyId/suppliers", + "POST /api/v1/companies/:companyId/suppliers/bulk-create", + "POST /api/v1/companies/:companyId/transactions/:id/categorize", + "POST /api/v1/companies/:companyId/transactions/:id/match-invoice", + "POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice", + "POST /api/v1/companies/:companyId/transactions/:id/uncategorize", + "POST /api/v1/companies/:companyId/transactions/batch-categorize", + "POST /api/v1/companies/:companyId/transactions/ingest", + "POST /api/v1/companies/:companyId/voucher-gap-explanations", + "POST /api/v1/companies/:companyId/webhooks", + "POST /api/v1/companies/:companyId/webhooks/:id/test", + "POST /api/v1/webhook-deliveries/:id/retry", +] +`; + +exports[`v1 spec snapshot > matches the recorded scope catalogue > endpoint-scopes 1`] = ` +[ + "bookkeeping:write", + "companies:read", + "compliance:read", + "customers:read", + "customers:write", + "documents:read", + "documents:write", + "invoices:read", + "invoices:write", + "operations:read", + "payroll:read", + "payroll:write", + "public", + "reports:read", + "suppliers:read", + "suppliers:write", + "transactions:read", + "transactions:write", + "webhooks:manage", +] +`; diff --git a/lib/api/v1/__tests__/spec-snapshot.test.ts b/lib/api/v1/__tests__/spec-snapshot.test.ts new file mode 100644 index 00000000..4075b947 --- /dev/null +++ b/lib/api/v1/__tests__/spec-snapshot.test.ts @@ -0,0 +1,80 @@ +/** + * Spec-snapshot test. + * + * Locks down the high-level shape of the v1 endpoint registry so an + * unintentional Zod-schema change can't ship a silent API break. CI fails + * if any of the following invariants drift unexpectedly: + * + * - Endpoint count + * - Endpoint key set (method + path tuples) + * - Set of distinct scopes referenced across all endpoints + * + * When you intentionally add or remove an endpoint, run the test once + * locally with `--update` to refresh the snapshot, review the diff, and + * commit the new snapshot alongside the route change. The diff itself + * becomes a self-describing API changelog entry. + * + * Why this lives here and not in tests/: the snapshot must be loaded + * relative to a path the load-routes side-effect import resolves from. + * Co-locating with the registry keeps the dependency cycle minimal. + */ + +import { describe, expect, it } from 'vitest' +import { listEndpoints } from '../registry' +// Side-effect import — every route file's registerEndpoint() runs at +// module load time and populates the shared ENDPOINTS map. +import '../load-routes' + +describe('v1 spec snapshot', () => { + const endpoints = listEndpoints() + + it('matches the recorded endpoint count', () => { + // Update intentionally when adding/removing endpoints. The count is + // the cheapest first-line check — if it changes unexpectedly, CI + // surfaces the surprise before reviewers have to spot it in the diff. + expect(endpoints.length).toMatchSnapshot('endpoint-count') + }) + + it('matches the recorded endpoint key set', () => { + const keys = endpoints + .map((e) => `${e.method} ${e.path}`) + .sort() + expect(keys).toMatchSnapshot('endpoint-keys') + }) + + it('matches the recorded scope catalogue', () => { + const scopes = Array.from( + new Set(endpoints.map((e) => e.scope ?? 'public')), + ).sort() + expect(scopes).toMatchSnapshot('endpoint-scopes') + }) + + it('every endpoint has the agent-facing metadata that the docs depend on', () => { + // The /docs/api/reference pages and /llms-full.txt aggregator both + // assume every endpoint registers complete metadata. A registerEndpoint + // call that omits any of these fields would render a page with empty + // sections — surface the omission here instead. + for (const ep of endpoints) { + const ctx = `${ep.method} ${ep.path}` + expect(ep.summary, `${ctx}: missing summary`).toBeTruthy() + expect(ep.description, `${ctx}: missing description`).toBeTruthy() + expect(ep.useWhen, `${ctx}: missing useWhen`).toBeTruthy() + expect(ep.doNotUseFor, `${ctx}: missing doNotUseFor`).toBeTruthy() + expect(Array.isArray(ep.pitfalls), `${ctx}: pitfalls must be an array`).toBe(true) + expect(ep.example, `${ctx}: missing example`).toBeTruthy() + expect(ep.example.response, `${ctx}: example.response is required`).toBeTruthy() + + // Defense-in-depth: every endpoint MUST explicitly declare its + // scope (or the literal sentinel `null` for genuinely public + // endpoints — e.g. /api/v1/health). `undefined` means the + // registerEndpoint call silently dropped the field, which would + // make the wrapper treat the route as unauthenticated. CC6.3 — + // surfacing the omission in CI prevents accidental public + // exposure of new endpoints. + expect( + ep.scope !== undefined, + `${ctx}: scope must be explicitly declared (use null for genuinely public endpoints)`, + ).toBe(true) + } + }) +}) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 671c3d54..d0d37ad5 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -119,4 +119,11 @@ import '@/app/api/v1/companies/[companyId]/reports/sie-export/route' import '@/app/api/v1/companies/[companyId]/imports/sie/route' import '@/app/api/v1/companies/[companyId]/imports/bank/route' +// Phase 6 PR-1 — webhooks substrate. +import '@/app/api/v1/companies/[companyId]/webhooks/route' +import '@/app/api/v1/companies/[companyId]/webhooks/[id]/route' +import '@/app/api/v1/companies/[companyId]/webhooks/[id]/test/route' +import '@/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route' +import '@/app/api/v1/webhook-deliveries/[id]/retry/route' + export {} diff --git a/lib/docs/content/changelog.ts b/lib/docs/content/changelog.ts new file mode 100644 index 00000000..b5b886e1 --- /dev/null +++ b/lib/docs/content/changelog.ts @@ -0,0 +1,73 @@ +import { API_V1_VERSION } from '@/lib/api/v1/version' + +export const CHANGELOG_MD = `# Changelog + +> Reverse-chronological release notes for the gnubok REST API. Versions follow Stripe's dated format (\`YYYY-MM-DD\`). The current version is **\`${API_V1_VERSION}\`**. + +--- + +## ${API_V1_VERSION} *(current)* + +The first stable release of the public REST API. Six phases of development covering the full agent-native surface: authentication + discovery, invoicing vertical, transactions vertical, bookkeeping engine + suppliers + compliance check, payroll + reports + import, webhooks. + +### Authentication + discovery (Phase 1) + +- API key auth via \`Authorization: Bearer gnubok_sk__\`. 100 RPM rate limit per key. +- \`gnubok_sk_test_*\` keys bound to deterministic sandbox companies. +- Scope-based authorisation per endpoint (\`invoices:read\`, \`payroll:write\`, \`webhooks:manage\`, ...). +- Discovery: \`GET /llms.txt\`, \`GET /api/v1/openapi.json\`, \`GET /.well-known/skills/index.json\`. +- Health: \`GET /api/v1/health\`. +- Response envelope: \`{ data, meta: { request_id, api_version, audit, next_cursor } }\`. +- \`X-Request-Id\` on every response; idempotency on every write. + +### Invoices vertical (Phase 2) + +- **Customers**: GET list + detail, POST create + bulk-create, PATCH, DELETE. +- **Invoices**: GET list + detail, POST create, PATCH, lifecycle verbs \`/mark-sent\`, \`/mark-paid\`, \`/credit\`, \`/send\`, \`/bulk-create\`. PDF download at \`/{id}/pdf\`. +- VIES validation runs on commit for EU-business customers with a VAT number. +- Mixed-rate invoices supported — per-item \`vat_rate\` overrides the header rate. +- ROT/RUT-avdrag flow and supplier-invoice fakturamodellen on the AP side. + +### Transactions vertical (Phase 3) + +- **Transactions**: cursor-paginated GET list + detail. Single-tx verbs \`/categorize\`, \`/uncategorize\`, \`/match-invoice\`, \`/match-supplier-invoice\`. Bulk \`/ingest\` (up to 500), \`/batch-categorize\` (up to 100). +- **Reconciliation**: \`POST /reconciliation/bank/run\`, \`GET /reconciliation/bank/status\`. +- **Reads**: \`GET /accounts\`, \`GET /fiscal-periods\`. +- All write surfaces honour strict-mode (commit fully or error with no side effects). + +### Bookkeeping primitives + AP + compliance (Phase 4) + +- **Suppliers + supplier-invoices** vertical (mirror of Phase 2 invoices on the AP side). +- **Journal entries** primitives: \`POST /journal-entries\` (draft+commit), \`/{id}/commit\`, \`/{id}/reverse\` (storno) and \`/{id}/correct\` (rättelse) — both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse), \`/batch-create\`. +- **Voucher gap explanations**: \`POST /voucher-gap-explanations\` per BFNAR 2013:2. +- **Fiscal-periods async ops**: \`/lock\`, \`/close\`, \`/year-end\`, \`/opening-balances\`, \`/currency-revaluation\`. All return 202 with operation_id; poll at \`GET /api/v1/operations/{id}\`. +- **Compliance check**: \`GET /compliance/check?type={year_end_readiness|voucher_gaps}\` — pre-flight findings before submission. +- **Documents**: \`POST /documents\` (multipart upload, magic-number-checked), \`GET /{id}/download\` (15-min signed URL), \`POST /{id}/link\` (attach to journal entry). + +### Payroll + reports + import (Phase 5) + +- **Employees**: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via \`is_active\`. +- **Salary runs**: CRUD + lifecycle verbs \`/calculate\`, \`/approve\`, \`/mark-paid\`, \`/book\`, \`/generate-agi\`. State machine: draft → review → approved → paid → booked. \`/generate-agi\` produces and persists the arbetsgivardeklaration XML — the response carries it as \`data.xml\` for the integrator to upload to Skatteverket Mina Sidor (or via the optional \`skatteverket\` extension). gnubok does NOT auto-submit; the AGI deadline — **the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th** — is the integrator's responsibility. +- **JSON reports** (14): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability. +- **Binary report**: \`GET /reports/sie-export\` (text/plain SIE4 file). Note: a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations — SIE captures account-level positions and verifikationer but lacks system documentation and behandlingshistorik. Treat SIE as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive. +- **Async imports**: \`POST /imports/sie\` (multipart, 50 MB), \`POST /imports/bank\` (multipart, 10 MB, auto-format detection across 11 bank formats). Both async via \`operations\` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via \`/settings/tax-codes\`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately — under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. gnubok starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record. + +### Webhooks (Phase 6 PR-1) *— shipped 2026-05-15* + +- **Subscriptions**: \`POST /webhooks\` (HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (\`salary_run.*\` and \`agi.generated\` require \`payroll:read\`). +- **Delivery substrate**: per-minute Vercel cron at \`/api/webhooks/dispatch/cron\`. Exponential backoff \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72h total). HTTP 410 from receiver auto-disables the webhook. +- **Signature**: \`X-Gnubok-Signature: t=,v1=\`. Stripe-format. Sample receivers in [Node + Python](/docs/api/webhooks#verifying-signatures). +- **SSRF protection**: webhook_url must be HTTPS; resolved IPs in private/loopback/link-local/CGNAT/cloud-metadata ranges are rejected at create AND dispatch time. \`redirect: 'error'\` on every outbound POST. +- **Audit + retention**: webhook delivery rows are *behandlingshistorik* per BFNAR 2013:2 kap 8 § — immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. gnubok keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (\`ON DELETE SET NULL\` on \`webhook_id\`). +- **Verbs**: \`POST /webhooks/{id}/test\` enqueues a synthetic event; \`POST /webhook-deliveries/{id}/retry\` re-enqueues a dead/delivered delivery. + +### Coming soon (Phase 6 PR-2 hardening) + +- 90-day TTL cleanup cron for non-accounting webhook deliveries +- Per-route rate limits on \`:test\`, \`:retry\`, and webhook \`:create\` +- V16 audit-log entries on webhook lifecycle events +- DNS-rebinding pinned-IP HTTPS agent +- Integration tests + \`*.pg.test.ts\` for webhook triggers +- \`claim_due_webhook_deliveries\` SQL function with \`FOR UPDATE SKIP LOCKED\` +- Populated \`previous_attributes\` for update-style webhook events +` diff --git a/lib/docs/content/cookbook/index.ts b/lib/docs/content/cookbook/index.ts new file mode 100644 index 00000000..002a1424 --- /dev/null +++ b/lib/docs/content/cookbook/index.ts @@ -0,0 +1,107 @@ +/** + * Cookbook recipe registry. Two recipes ship in PR-2 (Phase 6 docs): + * - quickstart: send your first invoice (high-leverage onboarding path) + * - webhooks: end-to-end webhook setup with sig verification + retry handling + * + * The remaining 4 recipes from the docs nav (ingest-bank-transactions, + * file-vat-declaration, run-payroll-and-agi, year-end-closing) ship as + * placeholder pages pointing at the relevant API reference. They're + * scheduled for the docs polish follow-up after PR-3 hardening lands — + * Stripe-grade narrative quality benefits from its own focused pass. + */ + +import { QUICKSTART_MD } from './quickstart' +import { COOKBOOK_WEBHOOKS_MD } from './webhooks' + +interface CookbookEntry { + slug: string + title: string + /** Full markdown content, OR null if the recipe is a placeholder. */ + markdown: string | null + /** Where the placeholder points the reader if markdown is null. */ + referenceLink?: { href: string; label: string } + description: string +} + +export const COOKBOOK: CookbookEntry[] = [ + { + slug: 'quickstart', + title: 'Quickstart — send your first invoice', + markdown: QUICKSTART_MD, + description: 'Five minutes from a fresh sandbox to an emailed invoice.', + }, + { + slug: 'send-first-invoice', + title: 'Send your first invoice', + markdown: QUICKSTART_MD, // alias of quickstart for now + description: 'Create a customer, draft an invoice, send it, mark it paid.', + }, + { + slug: 'webhooks', + title: 'Set up webhooks and verify signatures', + markdown: COOKBOOK_WEBHOOKS_MD, + description: 'Subscribe to events, verify HMAC, handle retries idempotently.', + }, + { + slug: 'set-up-webhooks-and-verify-signatures', + title: 'Set up webhooks and verify signatures', + markdown: COOKBOOK_WEBHOOKS_MD, // alias matching docs nav + description: 'Subscribe to events, verify HMAC, handle retries idempotently.', + }, + { + slug: 'ingest-bank-transactions', + title: 'Ingest and categorise bank transactions', + markdown: null, + referenceLink: { href: '/docs/api/reference/transactions', label: 'Transactions reference' }, + description: 'Push CSV/CAMT into the engine, get AI suggestions, commit.', + }, + { + slug: 'file-vat-declaration', + title: 'Compute and review a VAT declaration', + markdown: null, + referenceLink: { href: '/docs/api/reference/reports#get-reports-vat-declaration', label: 'VAT declaration report' }, + description: 'Compute momsdeklaration rutor 05–62 and reconcile against the GL before manual submission to Skatteverket.', + }, + { + slug: 'run-payroll-and-agi', + title: 'Run payroll and generate the AGI XML', + markdown: null, + referenceLink: { href: '/docs/api/reference/salary-runs', label: 'Salary runs reference' }, + description: 'Calculate, approve, mark paid, book, generate the AGI XML for manual submission to Skatteverket Mina Sidor.', + }, + { + slug: 'year-end-closing', + title: 'Year-end closing', + markdown: null, + referenceLink: { href: '/docs/api/reference/fiscal-periods', label: 'Fiscal periods reference' }, + description: 'Lock periods, run year-end, set opening balances.', + }, +] + +export function findRecipe(slug: string): CookbookEntry | undefined { + return COOKBOOK.find((c) => c.slug === slug) +} + +export const COOKBOOK_SLUGS = COOKBOOK.map((c) => c.slug) + +export function buildPlaceholderMd(entry: CookbookEntry): string { + const link = entry.referenceLink + return [ + `# ${entry.title}`, + '', + `> ${entry.description}`, + '', + '## Coming soon', + '', + `This narrative cookbook recipe is in the queue alongside the Phase 6 PR-3 hardening work. The endpoints are live and documented — start from the [reference page](${link?.href ?? '/docs/api/reference'}) below and the [quickstart](/docs/api/cookbook/quickstart) for the auth + idempotency + dry-run patterns; the recipe will be a guided narrative on top.`, + '', + link + ? `**Reference:** [${link.label}](${link.href})` + : '**Reference:** [API reference](/docs/api/reference)', + '', + '**Related cookbooks already shipped:**', + '', + '- [Quickstart — send your first invoice](/docs/api/cookbook/quickstart)', + '- [Set up webhooks and verify signatures](/docs/api/cookbook/webhooks)', + ].join('\n') +} diff --git a/lib/docs/content/cookbook/quickstart.ts b/lib/docs/content/cookbook/quickstart.ts new file mode 100644 index 00000000..aa487ba7 --- /dev/null +++ b/lib/docs/content/cookbook/quickstart.ts @@ -0,0 +1,170 @@ +export const QUICKSTART_MD = `# Quickstart — send your first invoice + +> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, idempotency, and audit-block patterns you'll use everywhere. + +## What you'll need + +- A test API key (\`gnubok_sk_test_*\`) from the gnubok dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data — safe for evals. +- \`curl\` or any HTTP client. + +## 1. List the companies the key can access + +Test keys are scoped to a single sandbox company by default; this call confirms the auth works and returns the \`companyId\` you'll use in the rest of the cookbook. + +\`\`\`bash +curl https://gnubok.app/api/v1/companies \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response (truncated): + +\`\`\`json +{ + "data": [{ "id": "00000000-0000-0000-0000-000000000001", "name": "Sandbox AB", "org_number": "556677-8899", ... }], + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +Save the \`id\` as \`COMPANY_ID\` for the next steps. + +## 2. Create a customer (dry-run first) + +Every write supports \`?dry_run=true\` — the response shows the would-be record without committing. Use it in agent test loops to validate inputs before paying the side-effect cost. + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "name": "Acme AB", + "customer_type": "swedish_business", + "email": "ap@acme.test", + "org_number": "556677-8899", + "default_payment_terms": 30 + }' +\`\`\` + +Response (\`X-Dry-Run: true\` header, no row written): + +\`\`\`json +{ + "data": { + "id": null, + "name": "Acme AB", + "customer_type": "swedish_business", + "vat_number_validated": false, + "default_payment_terms": 30, + "created_at": null, + ... + }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +Drop \`?dry_run=true\` to commit. The response now carries a real \`id\` and \`created_at\`. + +## 3. Draft an invoice + +Invoices are typed (B2B, EU-business, individual) and support mixed-rate VAT (per-item \`vat_rate\` overrides). The minimum body: + +\`\`\`bash +INVOICE_IDEMP=$(uuidgen) +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $INVOICE_IDEMP" \\ + -H "Content-Type: application/json" \\ + -d '{ + "customer_id": "'$CUSTOMER_ID'", + "invoice_date": "2026-05-15", + "due_date": "2026-06-14", + "items": [ + { "description": "Konsultation, maj 2026", "quantity": 8, "unit_price": 1200, "vat_rate": 25 } + ] + }' +\`\`\` + +Response includes the auto-allocated invoice number, the computed VAT lines, and the audit block (the verifikation hasn't been posted yet — drafts are not yet räkenskapsinformation): + +\`\`\`json +{ + "data": { + "id": "...", + "invoice_number": "2026-0001", + "subtotal": 9600.00, + "vat_total": 2400.00, + "total": 12000.00, + "status": "draft", + "items": [...] + }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12", "audit": {...} } +} +\`\`\` + +## 4. Send it + +\`POST /invoices/{id}/send\` posts the verifikation, generates the PDF, and emails the customer in a single transaction. Strict-mode: if any step fails, none of them commit. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Response carries the now-posted voucher number: + +\`\`\`json +{ + "data": { + "id": "...", + "status": "sent", + "sent_at": "2026-05-15T12:00:00Z", + ... + }, + "meta": { + "request_id": "req_...", + "audit": { + "voucher_number": "F-2026-001", + "voucher_url": "https://gnubok.app/bookkeeping/...", + "immutable_at": "2026-05-15T12:00:00Z" + } + } +} +\`\`\` + +## 5. Mark it paid + +When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }' +\`\`\` + +## What just happened + +You created a customer, drafted an invoice with one mixed-VAT line item, posted the verifikation, sent the PDF, and recorded the payment. Five API calls; the engine handled BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering. + +The rendered PDF that the customer received contains every field required by ML 17 kap 24 § (the Swedish faktura mandate) — including \`beskattningsunderlag per skattesats\` (taxable amount per VAT rate; one line per distinct rate on multi-rate invoices), the supplier's organisationsnummer, sequential invoice number, per-line VAT rate, and the supply date. **Pass \`delivery_date\` explicitly** when goods or services are delivered on a different date than the invoice date — ML 17 kap 24 § field 7 requires the supply date and the API does NOT default it to \`invoice_date\`; a faktura with no supply date is non-compliant. + +The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt) — omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when \`company_settings.has_f_skatt\` is true. **The integrator is responsible for keeping \`has_f_skatt\` in sync with the company's live Skatteverket registration status.** Update via \`PATCH /api/v1/companies/{companyId}/settings\` or the settings page — a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note. + +The summary fields in the JSON response (\`subtotal\`, \`vat_total\`, \`total\`) are convenience aggregates for the integration; the binding faktura content is the PDF itself. + +## Next steps + +- **[Subscribe to invoice events](/docs/api/cookbook/webhooks)** — get notified when invoices are paid via webhooks instead of polling. +- **[Ingest bank transactions](/docs/api/cookbook/ingest-bank-transactions)** — push CAMT/CSV into the engine and auto-categorise. +- **[Run a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute momsdeklaration rutor and submit to Skatteverket. +- **[Full Invoices reference](/docs/api/reference/invoices)** — every endpoint, all the optional fields. + +## Common pitfalls + +- **Idempotency keys must be UUIDs.** Calls with non-UUID keys are rejected with \`VALIDATION_ERROR\`. Generate one per logical action and reuse it across retries of that same action — never on a fresh attempt. +- **Test keys can't email real addresses.** \`gnubok_sk_test_*\` short-circuits external providers — \`/send\` returns success but no email goes out. The PDF is still generated and the voucher posted. +- **Period locks block writes.** If you try to invoice into a closed period (\`invoice_date\` falls inside a locked fiscal period), the response is \`PERIOD_LOCKED\` (400). Use \`GET /fiscal-periods\` to check before backdating. +- **VIES VAT validation runs on commit only.** Dry-run skips the external VIES call; the real commit will block on slow VIES responses (we time out after 5s, but that's still 5s added to the request). Pre-validate via \`POST /api/v1/vat/validate\` if you want a fast first pass. +` diff --git a/lib/docs/content/cookbook/webhooks.ts b/lib/docs/content/cookbook/webhooks.ts new file mode 100644 index 00000000..1838c6b2 --- /dev/null +++ b/lib/docs/content/cookbook/webhooks.ts @@ -0,0 +1,188 @@ +export const COOKBOOK_WEBHOOKS_MD = `# Cookbook — set up webhooks and verify signatures end-to-end + +> Subscribe a receiver to invoice events, verify HMAC signatures correctly, handle the at-least-once retry semantics, and build idempotency around the delivery id. + +This is the operational companion to the [Webhooks concept page](/docs/api/webhooks) — that page explains *what* webhooks are; this one walks through *how* to wire one up correctly the first time. + +## What you'll need + +- A test API key with \`webhooks:manage\` scope (and \`payroll:read\` if you intend to subscribe to payroll events). +- A receiver URL that gnubok can POST to. For local development use [smee.io](https://smee.io) or \`ngrok\` — gnubok refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly. +- HTTPS only — \`http://\` URLs are rejected at registration. + +## 1. Register the webhook + +The response includes the HMAC signing secret **exactly once**. Capture it immediately and store it on the receiver side as an environment variable. + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "event_type": "invoice.paid", + "webhook_url": "https://my-receiver.example.com/gnubok", + "name": "CRM sync — invoice paid" + }' +\`\`\` + +Response: + +\`\`\`json +{ + "data": { + "id": "wh_a8f1...", + "name": "CRM sync — invoice paid", + "event_type": "invoice.paid", + "webhook_url": "https://my-receiver.example.com/gnubok", + "active": true, + "api_version_pinned": "2026-05-12", + "secret": "whsec_b3a7c9e2...", + "created_at": "2026-05-15T12:00:00Z" + }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +> ⚠️ The \`secret\` field is returned only on creation. Subsequent GETs never include it. If you lose it, the recovery path is to delete the webhook and create a new one (which generates a fresh secret); receivers must re-deploy with the new value. + +**Store the secret in a secrets manager** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext \`.env\` file or a config commit. The secret is signing material — anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password. + +## 2. Implement signature verification + +Use the [Node](https://gnubok.app/docs/api/webhooks#nodejs) or [Python](https://gnubok.app/docs/api/webhooks#python) sample on the concept page. The critical detail: capture the **raw request body** before any framework JSON-parses it. Re-serialising the body produces different bytes and the signature won't match. + +For an Express handler, that means \`express.raw({ type: 'application/json' })\` — NOT the default \`express.json()\` middleware. For FastAPI / Flask use \`request.get_data()\`. For Cloudflare Workers use \`await request.text()\` BEFORE \`request.json()\`. + +## 3. Send a test event + +The \`:test\` verb enqueues a synthetic \`webhook.test\` delivery without driving real state. The dispatcher sends it on the next per-minute cron tick. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response: + +\`\`\`json +{ + "data": { "webhook_delivery_id": "wh_dlv_...", "status": "pending" }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +Wait up to 60s, then check the receiver logs. The delivery should arrive with: + +\`\`\` +POST /gnubok HTTP/1.1 +Content-Type: application/json +X-Gnubok-Signature: t=1715797800,v1=2f5c... +X-Gnubok-Event: webhook.test +X-Gnubok-Delivery: wh_dlv_... +X-Gnubok-Api-Version: 2026-05-12 + +{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from gnubok","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null} +\`\`\` + +If your receiver returns 2xx, the delivery moves to \`delivered\`. If it returns 4xx (other than 410) or 5xx, it goes to \`failed\` and retries on the schedule \`1m / 5m / 30m / 2h / 12h / 24h / 48h\`. + +## 4. Inspect the delivery + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/deliveries?delivery_id=$DELIVERY_ID" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response carries the captured response status and body (truncated to 4 KB), which is invaluable when debugging a 4xx from the receiver: + +\`\`\`json +{ + "data": [{ + "id": "wh_dlv_...", + "event_type": "webhook.test", + "status": "delivered", + "attempts": 1, + "next_attempt_at": "2026-05-15T12:00:00Z", + "response_status": 200, + "response_body": "ok", + "error": null, + "request_id": "whdel_...", + "created_at": "2026-05-15T12:00:00Z", + "delivered_at": "2026-05-15T12:00:01Z" + }] +} +\`\`\` + +## 5. Drive a real event + +Now mark a real invoice paid (or use any of the [event-emitting endpoints](/docs/api/webhooks#event-types)). The webhook handler picks up the emission and enqueues a delivery within the same request cycle. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }' +\`\`\` + +The next dispatcher tick (within 60s) delivers an \`invoice.paid\` event to your receiver carrying the full invoice payload + payment details. + +## Idempotency on the receiver side + +Deliveries are at-least-once. The same \`X-Gnubok-Delivery\` may arrive twice when the network drops a 200 response or your receiver times out after processing. Build idempotency around that header: + +\`\`\`javascript +// Pseudo-code — adapt to your storage layer. +async function handleEvent(event) { + const inserted = await db.processedDeliveries.insertIfMissing({ + delivery_id: event.id, + event_type: event.type, + received_at: new Date(), + }) + if (!inserted) { + console.log('duplicate delivery, skipping', event.id) + return + } + await processBusinessLogic(event) +} +\`\`\` + +This pattern: a unique constraint on \`delivery_id\`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every gnubok delivery passes through that gate at most once even if the dispatcher retries. + +## Replaying a dead delivery + +When a delivery exhausts its retries it's marked \`dead\`. After fixing the receiver, replay individual deliveries with: + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers see the same \`X-Gnubok-Delivery\` (the new row's id, not the original's), so the idempotency table needs no special handling. + +## Auto-disable + +After: +- HTTP 410 Gone from your receiver, OR +- HTTP 3xx redirect (refused to follow — SSRF policy), OR +- The webhook URL resolves to a private/loopback/link-local/cloud-metadata IP at dispatch time + +…the webhook is automatically disabled (\`active=false\`, \`disabled_reason\` set). Re-enable with: + +\`\`\`bash +curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Content-Type: application/json" \\ + -d '{ "active": true }' +\`\`\` + +This clears \`disabled_at\` and \`disabled_reason\` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint. + +## Common pitfalls + +- **Re-serialising the body.** \`JSON.parse(rawBody); JSON.stringify(parsed)\` produces different bytes than gnubok sent. Always sign-check against the raw bytes. +- **Forgetting the timestamp window.** Without a \`t\` check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance. +- **Returning 5xx for application errors.** A 5xx triggers full retries (~72h). If a payload is malformed-but-stable, return 200 and queue for internal investigation. +- **Treating \`failed\` as terminal.** \`failed\` rows will retry; only \`delivered\` and \`dead\` are terminal. Don't alert on \`failed\` — alert when retries exhaust to \`dead\`. +` diff --git a/lib/docs/content/errors.ts b/lib/docs/content/errors.ts new file mode 100644 index 00000000..ac1d1d06 --- /dev/null +++ b/lib/docs/content/errors.ts @@ -0,0 +1,141 @@ +/** + * /docs/api/errors content — generated from the STRUCTURED_ERRORS registry. + * + * The registry lives in lib/errors/structured-errors.ts; we re-import it here + * and build a Stripe-style catalogue page where every code is anchorable + * (the docs_url field on every error envelope already points at this page). + * + * Adding a new error code in the registry automatically surfaces here on the + * next build — no manual edits to keep in sync. + */ + +import { listErrorCodes, getErrorEntry } from '@/lib/errors/structured-errors' + +interface DomainGroup { + label: string + description: string + /** Code prefix matchers — first match wins; codes without a match fall to 'Other'. */ + prefixes: string[] +} + +const DOMAINS: DomainGroup[] = [ + { label: 'Generic', description: 'Cross-cutting codes returned by any endpoint.', prefixes: ['UNKNOWN_', 'INTERNAL_', 'VALIDATION_', 'UNAUTHORIZED', 'MFA_', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'RATE_LIMITED', 'NOT_IMPLEMENTED', 'COMPANY_CONTEXT_', 'IDEMPOTENCY_', 'INSUFFICIENT_SCOPE'] }, + { label: 'Bookkeeping engine', description: 'Errors from the journal-entry lifecycle (create, commit, reverse, correct).', prefixes: ['BOOKKEEPING_', 'JOURNAL_', 'VOUCHER_'] }, + { label: 'Periods + year-end', description: 'Fiscal period locking, year-end closing, opening balances, FX revaluation.', prefixes: ['PERIOD_', 'YEAR_END_', 'OPENING_BALANCE_', 'FX_'] }, + { label: 'Invoices', description: 'Customer invoice lifecycle: draft, send, mark paid, credit.', prefixes: ['INVOICE_', 'CREDIT_NOTE_', 'CUSTOMER_'] }, + { label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit.', prefixes: ['SUPPLIER_INVOICE_', 'SUPPLIER_'] }, + { label: 'Transactions', description: 'Bank transaction ingest, categorisation, matching.', prefixes: ['TRANSACTION_', 'MATCH_INVOICE_', 'MATCH_SI_', 'MATCH_'] }, + { label: 'Reports', description: 'Report generation: VAT declaration, periodisk sammanställning, SIE export, INK2.', prefixes: ['REPORT_', 'VAT_', 'PS_', 'SIE_EXPORT_', 'TAX_DECL_'] }, + { label: 'Imports', description: 'SIE import, bank file import, opening-balance import, provider migration.', prefixes: ['SIE_IMPORT_', 'BANK_FILE_', 'OPENING_BALANCE_IMPORT_', 'REGISTER_IMPORT_', 'PROVIDER_MIGRATION_'] }, + { label: 'Documents', description: 'Document upload, link, signed-URL download, retention.', prefixes: ['DOCUMENT_'] }, + { label: 'Salary + AGI', description: 'Payroll lifecycle, AGI generation, KU declarations.', prefixes: ['SALARY_', 'AGI_', 'KU_', 'EMPLOYEE_'] }, + { label: 'Company + API keys', description: 'Multi-tenant + auth lifecycle.', prefixes: ['COMPANY_', 'API_KEY_'] }, + { label: 'Provider connections', description: 'External provider OAuth, sync, consent.', prefixes: ['PROVIDER_'] }, +] + +function classify(code: string): string { + for (const group of DOMAINS) { + for (const prefix of group.prefixes) { + if (code.startsWith(prefix)) return group.label + } + } + return 'Other' +} + +function statusLabel(status: number): string { + switch (status) { + case 400: return 'Bad request' + case 401: return 'Unauthorized' + case 403: return 'Forbidden' + case 404: return 'Not found' + case 409: return 'Conflict' + case 422: return 'Unprocessable' + case 429: return 'Rate limited' + case 500: return 'Server error' + case 501: return 'Not implemented' + default: return '' + } +} + +export function buildErrorReferenceMd(): string { + const codes = listErrorCodes().sort() + const grouped = new Map() + + for (const code of codes) { + const domain = classify(code) + if (!grouped.has(domain)) grouped.set(domain, []) + grouped.get(domain)!.push(code) + } + + // Render groups in the order DOMAINS declares, with Other last. + const orderedLabels = [...DOMAINS.map((d) => d.label), 'Other'] + + const lines: string[] = [] + lines.push('# Errors') + lines.push('') + lines.push(`> Every error returned by the gnubok REST API uses a stable code from this catalogue. Codes never change once shipped — agents can pattern-match on them safely. The \`docs_url\` field on every error envelope points at the anchor for that specific code.`) + lines.push('') + lines.push('## Envelope shape') + lines.push('') + lines.push('```json') + lines.push('{') + lines.push(' "error": {') + lines.push(' "code": "PERIOD_LOCKED",') + lines.push(' "message": "Den valda perioden är låst.",') + lines.push(' "message_en": "The selected period is locked.",') + lines.push(' "remediation": {') + lines.push(' "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",') + lines.push(' "tool": "fiscal_periods.unlock"') + lines.push(' },') + lines.push(' "details": { "fiscal_period_id": "..." },') + lines.push(' "docs_url": "https://gnubok.app/docs/api/errors#period_locked"') + lines.push(' },') + lines.push(' "meta": { "request_id": "req_...", "api_version": "..." }') + lines.push('}') + lines.push('```') + lines.push('') + lines.push(`The \`message\` field is Swedish (matches the dashboard); \`message_en\` is English (for agent and developer logs); \`remediation\` (when present) hints at the canonical fix and may include a \`tool\` reference into the MCP surface.`) + lines.push('') + + for (const label of orderedLabels) { + const codes = grouped.get(label) + if (!codes || codes.length === 0) continue + const desc = DOMAINS.find((d) => d.label === label)?.description ?? '' + + lines.push(`## ${label}`) + lines.push('') + if (desc) { + lines.push(`*${desc}*`) + lines.push('') + } + + for (const code of codes) { + const entry = getErrorEntry(code) + if (!entry) continue + const status = entry.httpStatus + const statusName = statusLabel(status) + lines.push(`### ${code}`) + lines.push('') + lines.push(`**HTTP \`${status}\`**${statusName ? ` — ${statusName}` : ''}`) + lines.push('') + lines.push(`${entry.message_en}`) + lines.push('') + if (entry.message_sv) { + lines.push(`**Swedish:** ${entry.message_sv}`) + lines.push('') + } + if (entry.remediation) { + lines.push(`**Remediation:** ${entry.remediation.description}`) + if (entry.remediation.tool) { + lines.push(`Related tool: \`${entry.remediation.tool}\``) + } + if (entry.remediation.resource) { + lines.push(`Related resource: \`${entry.remediation.resource}\``) + } + lines.push('') + } + } + } + + return lines.join('\n') +} diff --git a/lib/docs/content/landing.ts b/lib/docs/content/landing.ts new file mode 100644 index 00000000..e32e17cc --- /dev/null +++ b/lib/docs/content/landing.ts @@ -0,0 +1,109 @@ +import { API_V1_VERSION } from '@/lib/api/v1/version' + +export const LANDING_MD = `# gnubok API + +> Swedish double-entry bookkeeping as a public REST API for agents and integrations. API version \`${API_V1_VERSION}\`. + +The gnubok API lets you do anything the dashboard can do — create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write. + +If you've used [Stripe's API](https://docs.stripe.com/api), the shape will feel familiar — bearer-token auth, dated API versions, webhook signature verification, idempotency keys. The accounting concepts are Swedish (BAS chart, BFL retention, K2/K3, momsdeklaration) but the surface is built for the same kind of integrator. + +## Authentication + +All requests authenticate with a bearer token in the \`Authorization\` header: + +\`\`\`bash +curl https://gnubok.app/api/v1/companies \\ + -H "Authorization: Bearer gnubok_sk_live_..." +\`\`\` + +Create keys in the gnubok dashboard at **/settings/api**. Two key prefixes are available: + +- \`gnubok_sk_live_*\` — hits real customer data. Use in production. +- \`gnubok_sk_test_*\` — bound to deterministic sandbox companies. Safe for evals, demos, and agent learning. Same surface, different blast radius. + +Each key carries one or more **scopes** (\`invoices:read\`, \`invoices:write\`, \`payroll:write\`, \`webhooks:manage\`, ...) that gate which endpoints it can call. Scopes are listed on every endpoint reference page. + +Rate limit: 100 requests per minute per key, returned in \`X-RateLimit-*\` headers. + +## Base URL + +\`\`\` +https://gnubok.app/api/v1 +\`\`\` + +URLs include the company id explicitly: + +\`\`\` +GET /api/v1/companies/{companyId}/invoices +POST /api/v1/companies/{companyId}/invoices +\`\`\` + +A multi-company key can act on any company the underlying user is a member of — the URL is the source of truth, not a default. List the companies a key can access with: + +\`\`\`bash +curl https://gnubok.app/api/v1/companies \\ + -H "Authorization: Bearer gnubok_sk_live_..." +\`\`\` + +## Core principles + +These four invariants hold across the entire surface — once you've internalised them you can predict the shape of any endpoint without reading the reference. + +**Dry-run on every write.** Append \`?dry_run=true\` (or send \`X-Dry-Run: true\`) to any POST/PATCH/DELETE to preview the effect — the response shows the journal lines, voucher number, account deltas, and any validation errors that would surface, but commits nothing. Use this in agent test-loops to validate inputs before paying the side-effect cost. + +**Idempotency-Key on every write.** Pass a UUID in the \`Idempotency-Key\` header. Replays of the same key+body return the original response with \`Idempotent-Replayed: true\` (24h cache). Replays with a different body return \`409 IDEMPOTENCY_KEY_REUSE\`. + +**Strict-mode write semantics.** A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" — either both happened or neither did. + +**Inline audit on every write.** Every successful write response includes an \`audit\` block in \`meta\` with the voucher number, audit-trail URL, and immutability timestamp. No second round-trip needed to confirm what happened. + +## Response envelope + +Every response has the same shape: + +\`\`\`json +{ + "data": { ... }, + "meta": { + "request_id": "req_...", + "api_version": "${API_V1_VERSION}", + "next_cursor": "...", + "audit": { "voucher_number": "A-2026-042", "voucher_url": "..." } + } +} +\`\`\` + +Errors swap \`data\` for \`error\`: + +\`\`\`json +{ + "error": { + "code": "PERIOD_LOCKED", + "message": "Den valda perioden är låst.", + "message_en": "The selected period is locked.", + "remediation": { "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.", "tool": "fiscal_periods.unlock" }, + "details": { "fiscal_period_id": "..." }, + "docs_url": "https://gnubok.app/docs/api/errors#period_locked" + }, + "meta": { "request_id": "req_...", "api_version": "${API_V1_VERSION}" } +} +\`\`\` + +Every error code is documented in the [error reference](/docs/api/errors). + +## Where to go next + +- **[Quickstart cookbook](/docs/api/cookbook/quickstart)** — send your first invoice in five minutes. +- **[API reference](/docs/api/reference)** — every endpoint, grouped by resource. +- **[Webhooks](/docs/api/webhooks)** — subscribe to events with HMAC-signed delivery. +- **[Errors](/docs/api/errors)** — every stable error code with remediation. +- **[Versioning](/docs/api/versioning)** — how API versions are pinned and upgraded. +- **[Changelog](/docs/api/changelog)** — what shipped when. + +For LLM-based agents: +- **[\`/llms.txt\`](/llms.txt)** — concise agent-discovery index. +- **[\`/llms-full.txt\`](/llms-full.txt)** — full docs concatenated for ingestion. +- **[\`/api/v1/openapi.json\`](/api/v1/openapi.json)** — machine-readable OpenAPI 3.1 spec. +- **[\`/.well-known/skills/index.json\`](/.well-known/skills/index.json)** — gnubok-specific skill catalogue. +` diff --git a/lib/docs/content/reference.ts b/lib/docs/content/reference.ts new file mode 100644 index 00000000..1f230e95 --- /dev/null +++ b/lib/docs/content/reference.ts @@ -0,0 +1,204 @@ +/** + * Auto-generated API reference pages. + * + * Iterates lib/api/v1/registry.ts ENDPOINTS, groups by resource (derived + * from the URL path), and renders one Markdown page per resource. Stripe- + * style: each endpoint section has the description, useWhen, doNotUseFor, + * pitfalls, scope, idempotent/reversible/dryRun flags, and a worked example. + * + * To make this work, every v1 route file needs to import-side-effect call + * registerEndpoint() — which they all do at module load time. The doc + * builder triggers that load via lib/api/v1/load-routes.ts. + * + * Adding a new endpoint means editing the route file's registerEndpoint + * call; the docs then surface it on the next build with no manual sync. + */ + +import { listEndpoints, type EndpointDefinition, type HttpMethod } from '@/lib/api/v1/registry' +// Side-effect import: every v1 route file's top-level registerEndpoint() +// call runs as a result of loading this module, populating the shared +// ENDPOINTS map that listEndpoints() reads from. +import '@/lib/api/v1/load-routes' + +interface ResourceGroup { + /** URL slug, used in /docs/api/reference/{slug}. */ + slug: string + /** Display label for headings + nav. */ + label: string + /** One-line description for the resource landing card. */ + description: string + /** URL pattern segment that identifies endpoints belonging to this resource. */ + matcher: (path: string) => boolean +} + +const RESOURCES: ResourceGroup[] = [ + { slug: 'companies', label: 'Companies', description: 'List and read companies the API key can access.', matcher: (p) => /\/companies(?:\/:companyId)?$/.test(p) }, + { slug: 'customers', label: 'Customers', description: 'CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.', matcher: (p) => /\/customers(\/|$)/.test(p) }, + { slug: 'invoices', label: 'Invoices', description: 'Outbound invoicing — draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.', matcher: (p) => /\/invoices(\/|$)/.test(p) }, + { slug: 'suppliers', label: 'Suppliers', description: 'AP-side counterparties. Mirrors customers on the supplier vertical.', matcher: (p) => /\/suppliers(\/|$)/.test(p) }, + { slug: 'supplier-invoices', label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.', matcher: (p) => /\/supplier-invoices(\/|$)/.test(p) }, + { slug: 'transactions', label: 'Transactions', description: 'Bank transactions — ingest, categorise, match to invoices, reconcile.', matcher: (p) => /\/transactions(\/|$)/.test(p) }, + { slug: 'reconciliation', label: 'Reconciliation', description: 'Run bank-to-ledger reconciliation and read the current matching status.', matcher: (p) => /\/reconciliation(\/|$)/.test(p) }, + { slug: 'journal-entries', label: 'Journal entries', description: 'The bookkeeping engine surface — verifikation lifecycle (draft, commit, reverse, correct).', matcher: (p) => /\/journal-entries(\/|$)/.test(p) }, + { slug: 'voucher-gap-explanations', label: 'Voucher gap explanations', description: 'Documented explanations for gaps in the voucher series, per BFNAR 2013:2.', matcher: (p) => /\/voucher-gap/.test(p) }, + { slug: 'fiscal-periods', label: 'Fiscal periods', description: 'Period lifecycle — lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.', matcher: (p) => /\/fiscal-periods(\/|$)/.test(p) }, + { slug: 'accounts', label: 'Accounts', description: 'Read the chart of accounts (BAS).', matcher: (p) => /\/accounts(\/|$)/.test(p) }, + { slug: 'documents', label: 'Documents', description: 'Multipart upload, signed-URL download (15-min TTL), link to journal entries.', matcher: (p) => /\/documents(\/|$)/.test(p) }, + { slug: 'employees', label: 'Employees', description: 'Payroll roster — CRUD with personnummer masking on list endpoints.', matcher: (p) => /\/employees(\/|$)/.test(p) }, + { slug: 'salary-runs', label: 'Salary runs', description: 'Payroll lifecycle — create, calculate, approve, mark paid, book, generate AGI XML.', matcher: (p) => /\/salary-runs(\/|$)/.test(p) }, + { slug: 'reports', label: 'Reports', description: 'Read-only reports — trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.', matcher: (p) => /\/reports(\/|$)/.test(p) }, + { slug: 'imports', label: 'Imports', description: 'Bulk async ingest — SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).', matcher: (p) => /\/imports(\/|$)/.test(p) }, + { slug: 'compliance', label: 'Compliance check', description: 'Pre-flight verification — voucher gaps, year-end readiness, before submitting to Skatteverket.', matcher: (p) => /\/compliance(\/|$)/.test(p) }, + { slug: 'webhooks', label: 'Webhooks', description: 'Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.', matcher: (p) => /\/webhooks|\/webhook-deliveries/.test(p) }, + { slug: 'operations', label: 'Operations', description: 'Poll long-running async operations (year-end closing, imports, currency revaluation).', matcher: (p) => /\/operations(\/|$)/.test(p) }, +] + +/** Discover the resource a given endpoint path belongs to. Returns null if it doesn't fit any. */ +function classifyEndpoint(path: string): ResourceGroup | null { + for (const r of RESOURCES) { + if (r.matcher(path)) return r + } + return null +} + +export interface BuiltResourcePage { + slug: string + label: string + description: string + endpoints: EndpointDefinition[] + markdown: string +} + +const METHOD_ORDER: Record = { GET: 0, POST: 1, PATCH: 2, PUT: 3, DELETE: 4 } + +function endpointAnchor(ep: EndpointDefinition): string { + return `${ep.method.toLowerCase()}-${ep.operation.replace(/\./g, '-')}` +} + +function renderEndpoint(ep: EndpointDefinition): string { + const lines: string[] = [] + const methodBadge = ep.method + lines.push(`### \`${methodBadge}\` ${ep.path} {#${endpointAnchor(ep)}}`) + lines.push('') + lines.push(`**\`${ep.operation}\`**${ep.scope ? ` · scope \`${ep.scope}\`` : ' · public'}`) + lines.push('') + lines.push(ep.summary) + lines.push('') + lines.push(ep.description) + lines.push('') + lines.push(`**Use when:** ${ep.useWhen}`) + lines.push('') + lines.push(`**Don't use for:** ${ep.doNotUseFor}`) + lines.push('') + if (ep.pitfalls.length > 0) { + lines.push('**Pitfalls**') + for (const p of ep.pitfalls) lines.push(`- ${p}`) + lines.push('') + } + const flags: string[] = [] + flags.push(`**Risk:** ${ep.risk}`) + flags.push(`**Idempotent:** ${ep.idempotent ? 'yes' : 'no'}`) + flags.push(`**Reversible:** ${ep.reversible ? 'yes' : 'no'}`) + flags.push(`**Dry-run supported:** ${ep.dryRunSupported ? 'yes' : 'no'}`) + lines.push(flags.join(' · ')) + lines.push('') + if (ep.example.request) { + lines.push('**Example request**') + lines.push('') + lines.push('```json') + lines.push(JSON.stringify(ep.example.request, null, 2)) + lines.push('```') + lines.push('') + } + lines.push('**Example response**') + lines.push('') + lines.push('```json') + lines.push(JSON.stringify(ep.example.response, null, 2)) + lines.push('```') + lines.push('') + return lines.join('\n') +} + +// Module-level memoisation. The endpoint registry is populated once at +// module load (via the side-effect import of load-routes) and is then +// immutable for the process lifetime. The Markdown serialisation is +// pure derivation — reusing a single result avoids repeated work on the +// .md route handlers (which Next.js doesn't statically pre-render) AND +// halves the cost on each generateMetadata + page render pair on the +// HTML routes. (Greptile P2, round 1.) +let cachedPages: BuiltResourcePage[] | null = null + +export function buildResourcePages(): BuiltResourcePage[] { + if (cachedPages) return cachedPages + + const all = listEndpoints() + const byResource = new Map() + for (const ep of all) { + const r = classifyEndpoint(ep.path) + if (!r) continue + if (!byResource.has(r.slug)) byResource.set(r.slug, []) + byResource.get(r.slug)!.push(ep) + } + + const pages = RESOURCES.map((r) => { + const endpoints = (byResource.get(r.slug) ?? []).sort((a, b) => { + const m = METHOD_ORDER[a.method] - METHOD_ORDER[b.method] + if (m !== 0) return m + return a.path.localeCompare(b.path) + }) + + const lines: string[] = [] + lines.push(`# ${r.label}`) + lines.push('') + lines.push(`> ${r.description}`) + lines.push('') + + if (endpoints.length === 0) { + lines.push('*No endpoints registered yet for this resource.*') + } else { + lines.push('## Endpoints') + lines.push('') + for (const ep of endpoints) { + lines.push(`- [\`${ep.method}\` \`${ep.path}\`](#${endpointAnchor(ep)}) — ${ep.summary}`) + } + lines.push('') + lines.push('---') + lines.push('') + for (const ep of endpoints) { + lines.push(renderEndpoint(ep)) + lines.push('---') + lines.push('') + } + } + + return { + slug: r.slug, + label: r.label, + description: r.description, + endpoints, + markdown: lines.join('\n'), + } + }) + + cachedPages = pages + return pages +} + +export function buildReferenceOverviewMd(): string { + const lines: string[] = [] + lines.push('# API reference') + lines.push('') + lines.push(`> Every endpoint exposed by the gnubok REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.`) + lines.push('') + lines.push('## Resources') + lines.push('') + for (const r of RESOURCES) { + lines.push(`### [${r.label}](/docs/api/reference/${r.slug})`) + lines.push('') + lines.push(r.description) + lines.push('') + } + return lines.join('\n') +} + +export const RESOURCE_SLUGS = RESOURCES.map((r) => r.slug) diff --git a/lib/docs/content/versioning.ts b/lib/docs/content/versioning.ts new file mode 100644 index 00000000..20d2480b --- /dev/null +++ b/lib/docs/content/versioning.ts @@ -0,0 +1,148 @@ +import { API_V1_VERSION } from '@/lib/api/v1/version' + +export const VERSIONING_MD = `# Versioning + idempotency + dry-run + +> Three guarantees that hold across the entire v1 surface: stable response shapes pinned per request, safe retries on every write, and previewable side effects on every mutation. Once you've internalised them you can predict the shape of any new endpoint without reading its reference. + +## Versioning + +The major version is encoded in the URL: \`/api/v1/\`. Within v1, the response shape is dated and pinned. The current version is **\`${API_V1_VERSION}\`**. + +Every response carries the active version in headers and the \`meta\` envelope: + +\`\`\` +Gnubok-Version: ${API_V1_VERSION} +\`\`\` +\`\`\`json +{ "data": {...}, "meta": { "request_id": "...", "api_version": "${API_V1_VERSION}" } } +\`\`\` + +### Pinning + +Webhooks are pinned to the API version active at creation time (the \`api_version_pinned\` column on the \`webhooks\` row). Payload shapes for *your* webhook will not change until you explicitly upgrade — even if we ship a new dated version that breaks the shape for newly-created webhooks. + +API requests pin per-request via the \`Gnubok-Version\` request header (planned for v1.x; today every request gets the current version): + +\`\`\`bash +curl https://gnubok.app/api/v1/companies \\ + -H "Authorization: Bearer ..." \\ + -H "Gnubok-Version: ${API_V1_VERSION}" +\`\`\` + +### Deprecation policy + +When we ship a new dated version that breaks an existing shape: + +1. The new version is dated forward (e.g. \`2026-08-01\`) and made the default for newly-created keys + webhooks. +2. The previous version stays available for at least **6 months** after the new version ships. +3. Deprecation appears in the [changelog](/docs/api/changelog) with the retirement date and a migration guide. +4. Three months before retirement, every response from a deprecated version stamps \`Gnubok-Deprecation: \` in headers. +5. Calls to a retired version receive HTTP 410 with code \`API_VERSION_RETIRED\`. + +We will not break a shape inside an active dated version. Additive changes (new optional response fields, new request fields with defaults, new endpoints) ship as patch updates and are always backwards-compatible. + +### What counts as a breaking change + +- Removing a response field +- Renaming a response field +- Changing the type of a response field +- Removing an endpoint +- Removing or narrowing a stable error code +- Tightening request validation in a way that rejects previously-accepted input +- Changing the URL of an existing endpoint + +What does NOT count as a breaking change: + +- Adding a new optional response field +- Adding a new optional request field with a default +- Adding a new endpoint +- Adding a new error code (we expand the catalogue freely; existing codes stay stable) +- Loosening request validation +- Performance improvements that don't change observable behaviour + +--- + +## Idempotency + +Every state-changing endpoint (POST, PATCH, DELETE) accepts an \`Idempotency-Key\` header. The key is a UUID you generate; the server caches the response keyed by \`(api_key_id, company_id, idempotency_key, request_body_hash)\` for 24 hours. + +### How it works + +- **First call with a fresh key** → executes normally; response is cached. +- **Replay with the same key + same body** → returns the cached response with \`Idempotent-Replayed: true\` header. The original side effects are NOT re-executed. +- **Replay with the same key + different body** → returns \`409 IDEMPOTENCY_KEY_REUSE\`. This indicates the key was reused incorrectly. +- **Two concurrent requests with the same key** → one wins, the other waits for the cached response. + +### Required vs supported + +Some endpoints **require** an Idempotency-Key (the create routes for resources that would be expensive to deduplicate after the fact: invoices, customers, supplier-invoices, webhooks). Calls without the header return \`400 VALIDATION_ERROR\` with field \`Idempotency-Key\`. + +Other endpoints **support** but don't require it. Sending one is always safe. + +### Pattern + +\`\`\`bash +curl https://gnubok.app/api/v1/companies/{cid}/invoices \\ + -H "Authorization: Bearer ..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "customer_id": "...", "items": [...] }' +\`\`\` + +In an agent loop, generate the key once at the *start* of an attempt and reuse it across every retry of that single logical action — never on a fresh attempt with new inputs. + +--- + +## Dry-run + +Every state-changing endpoint that supports dry-run (\`x-dry-run-supported: true\` in the OpenAPI spec) accepts \`?dry_run=true\` query param **or** \`X-Dry-Run: true\` header. The endpoint executes its full validation pipeline (Zod, business rules, period-lock checks, VAT-rate compatibility, cross-tenant guards, ...) but does NOT commit. The response shape matches a successful commit: + +- All \`validation_error\` shapes that a real commit would produce surface here. +- The response \`data\` shows the would-be record with \`id: null\`, timestamps \`null\`, and any auto-generated values (voucher number, invoice number) shown as \`null\` or as the value that *would* have been allocated. +- The response stamps \`X-Dry-Run: true\` in headers. + +Use dry-run to: + +- **Validate input shape** before paying the side-effect cost (especially in agent test loops). +- **Preview voucher lines** the engine would generate for a given invoice + VAT mix before committing. +- **Probe period-lock** on a date before scheduling work. + +Dry-run **does not** call external providers (VIES VAT validation, BankID, Skatteverket submission). Those run only on commit. + +--- + +## Strict-mode write semantics + +A v1 mutation either commits fully or returns a structured error code with no side effects. The dashboard soft-fails on partial writes (a human is there to retry); the v1 surface aborts. This means you never see "the invoice was sent but the email failed" or "the journal entry posted but the payment row didn't" — either both happened or neither did. + +When a multi-step write fails: + +- **Pre-engine failure** (validation, missing FK, period locked) → no rows written, structured error returned. +- **Post-engine failure** (engine call succeeded, follow-up step failed) → the engine's writes are reversed via \`reverseEntry()\` (storno), the failure surfaces with code matching the failed step (e.g. \`MATCH_INVOICE_TX_LINK_FAILED\`). + +Storno reversals are themselves immutable journal entries — the original audit trail remains visible per BFL 5 kap 5 §. \`reversal_journal_entry_id\` on the original row points at the storno. + +--- + +## Inline audit on every write + +Every successful write response carries an \`audit\` block in \`meta\`: + +\`\`\`json +{ + "data": {...}, + "meta": { + "request_id": "req_...", + "api_version": "${API_V1_VERSION}", + "audit": { + "voucher_number": "A-2026-042", + "voucher_url": "https://gnubok.app/bookkeeping/...", + "audit_trail_url": "https://gnubok.app/audit/req_...", + "immutable_at": "2026-05-15T12:00:00Z" + } + } +} +\`\`\` + +No second round-trip needed to confirm what happened — agents can chain follow-up work directly on the returned voucher number. +` diff --git a/lib/docs/content/webhooks.ts b/lib/docs/content/webhooks.ts new file mode 100644 index 00000000..c2dc634b --- /dev/null +++ b/lib/docs/content/webhooks.ts @@ -0,0 +1,241 @@ +export const WEBHOOKS_MD = `# Webhooks + +> Receive HMAC-signed POST notifications when state changes in gnubok — invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~72 hours. + +If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, gnubok POSTs each event with a signed JSON body, your receiver returns 2xx to acknowledge. The signature header format and retry policy are the same. The event types are gnubok-specific. + +## Lifecycle + +1. **Register a receiver** with [\`POST /api/v1/companies/{companyId}/webhooks\`](/docs/api/reference/webhooks#post-webhooks). The response includes an HMAC signing secret returned **exactly once** — store it on the receiver side immediately. If you lose it, delete the webhook and create a new one. +2. **gnubok emits events** internally (e.g. an invoice is marked paid via the dashboard or another API call). The webhook handler enqueues a delivery row. +3. **The dispatcher cron runs every minute**, signs the payload with HMAC-SHA256, and POSTs to your URL with a 10-second timeout. +4. **Your receiver verifies the signature**, processes the event idempotently, and returns 2xx. +5. **Failed deliveries retry** at \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72 hours total). After all attempts the delivery is marked \`dead\`. HTTP 410 from your receiver short-circuits to \`dead\` immediately and **auto-disables** the webhook. + +## Event types + +The following event types are deliverable as webhooks. Subscribing to a type that requires elevated scope (\`salary_run.*\` and \`agi.*\` need \`payroll:read\`) returns \`INSUFFICIENT_SCOPE\` at registration time. + +**Invoicing** +- \`invoice.created\` — draft invoice created +- \`invoice.sent\` — invoice marked sent (email delivered or external) +- \`invoice.paid\` — invoice fully paid +- \`credit_note.created\` — credit note issued + +**AP / suppliers** +- \`supplier.created\` +- \`supplier_invoice.registered\` +- \`supplier_invoice.approved\` +- \`supplier_invoice.paid\` +- \`supplier_invoice.credited\` +- \`supplier_invoice.uncredited\` — credit reversal + +**Customers** +- \`customer.created\` + +**Bookkeeping** +- \`journal_entry.committed\` — voucher posted (immutable from this point) +- \`journal_entry.reversed\` — storno entry posted +- \`journal_entry.corrected\` — rättelse via \`correctEntry\` (BFL 5 kap 5 §) + +**Transactions** +- \`transaction.categorized\` — bank transaction assigned an account + tax code +- \`transaction.reconciled\` — transaction matched to a posted entry + +**Periods** +- \`period.locked\` — fiscal period closed for writes +- \`period.unlocked\` — fiscal period reopened +- \`period.year_closed\` — full year-end procedure complete + +**Payroll** *(requires \`payroll:read\` scope alongside \`webhooks:manage\`)* +- \`salary_run.created\` +- \`salary_run.approved\` +- \`salary_run.booked\` — journal entries posted +- \`agi.generated\` — AGI XML produced + +**Documents** +- \`document.uploaded\` + +## Payload shape + +Every delivery wraps the event in a Stripe-style envelope: + +\`\`\`json +{ + "id": "wh_dlv_a8f1...", + "type": "invoice.paid", + "api_version": "2026-05-12", + "created": 1715797800, + "data": { + "object": { + "invoice": { "id": "...", "invoice_number": "2026-0042", "total": 12500.00, ... }, + "paymentAmount": 12500.00, + "paymentDate": "2026-05-15", + "companyId": "..." + } + }, + "previous_attributes": null +} +\`\`\` + +- \`id\` matches the \`webhook_delivery_id\` you can poll at [\`GET /webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries). +- \`api_version\` is the version pinned to your webhook at creation time. Payload shapes for *your* webhook will not change until you explicitly upgrade. +- \`previous_attributes\` carries the prior values of any fields that changed on update-style events (e.g. \`invoice.paid\` carries the prior invoice state). \`null\` for create-style events. + +## Request headers + +Every outbound POST carries: + +\`\`\` +POST /your-receiver-url HTTP/1.1 +Content-Type: application/json +User-Agent: gnubok-webhook/1 +X-Gnubok-Signature: t=1715797800,v1=2f5c... +X-Gnubok-Event: invoice.paid +X-Gnubok-Delivery: wh_dlv_a8f1... +X-Gnubok-Api-Version: 2026-05-12 +X-Request-Id: whdel_a8f1... +\`\`\` + +The \`X-Gnubok-Delivery\` header is the canonical correlation id — log it on receipt and use it to deduplicate retries (deliveries are at-least-once, so the same delivery id may arrive more than once after a network blip). + +## Verifying signatures + +The signature header has the format \`t=,v1=\`. The signed payload is \`\${t}.\${rawBody}\` — the timestamp is included so receivers can implement a replay window (we recommend rejecting deliveries with \`t\` more than 5 minutes old). + +You **must** verify the signature on every delivery before processing it. Without verification, anyone who learns your URL can forge events. + +### Node.js + +\`\`\`javascript +import crypto from 'node:crypto' +import express from 'express' + +const app = express() +const SECRET = process.env.GNUBOK_WEBHOOK_SECRET // whsec_... + +// Important: capture the RAW body before any JSON parsing — the signature +// is computed against the exact bytes gnubok sent, not a re-serialised JSON. +app.post( + '/webhook', + express.raw({ type: 'application/json' }), + (req, res) => { + const sigHeader = req.header('x-gnubok-signature') ?? '' + const rawBody = req.body.toString('utf8') + + if (!verifySignature(rawBody, sigHeader, SECRET)) { + return res.status(400).send('invalid signature') + } + + const event = JSON.parse(rawBody) + // Idempotency: process the delivery id once. + if (alreadyProcessed(event.id)) return res.status(200).send('ok') + handleEvent(event) + return res.status(200).send('ok') + }, +) + +function verifySignature(body, header, secret) { + const parts = Object.fromEntries( + header.split(',').map((p) => p.split('=', 2)), + ) + const t = Number.parseInt(parts.t, 10) + const v1 = parts.v1 + if (!t || !v1) return false + + // Reject deliveries older than 5 minutes — replay protection. + const ageSec = Math.floor(Date.now() / 1000) - t + if (Math.abs(ageSec) > 300) return false + + const expected = crypto + .createHmac('sha256', secret) + .update(\`\${t}.\${body}\`) + .digest('hex') + + // Constant-time comparison. + const expectedBuf = Buffer.from(expected, 'hex') + const actualBuf = Buffer.from(v1, 'hex') + if (expectedBuf.length !== actualBuf.length) return false + return crypto.timingSafeEqual(expectedBuf, actualBuf) +} +\`\`\` + +### Python + +\`\`\`python +import hmac +import hashlib +import json +import os +import time +from flask import Flask, request, abort + +app = Flask(__name__) +SECRET = os.environ["GNUBOK_WEBHOOK_SECRET"].encode("utf-8") # whsec_... + +@app.post("/webhook") +def webhook(): + raw_body = request.get_data() # bytes — must be the raw request body + sig_header = request.headers.get("X-Gnubok-Signature", "") + + if not verify_signature(raw_body, sig_header, SECRET): + abort(400, "invalid signature") + + event = json.loads(raw_body) + if already_processed(event["id"]): + return "", 200 + handle_event(event) + return "", 200 + + +def verify_signature(body: bytes, header: str, secret: bytes) -> bool: + parts = dict(p.split("=", 1) for p in header.split(",")) + try: + t = int(parts["t"]) + v1 = parts["v1"] + except (KeyError, ValueError): + return False + + # Replay protection: 5-minute window. + if abs(int(time.time()) - t) > 300: + return False + + signed = f"{t}.".encode("utf-8") + body + expected = hmac.new(secret, signed, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, v1) +\`\`\` + +### Common pitfalls + +- **Using parsed JSON instead of raw bytes.** Re-serialising the body (\`JSON.stringify(req.body)\`) produces different bytes than gnubok sent — the signature won't match. Capture the raw body before any framework parses it. +- **Forgetting the timestamp window.** Without checking \`t\`, an attacker who captured one signed payload can replay it forever. 5 minutes is our recommended window; tighten if your clock skew is small. +- **Treating retries as duplicates of failure.** Retries arrive when *we* didn't get a 2xx. A 200 response that arrives slowly may not reach us in time and we'll retry — your receiver sees the same \`X-Gnubok-Delivery\` twice. Idempotency is on you. +- **Returning 5xx for application errors.** A 5xx triggers the full retry policy (~72h of attempts). If your handler hit an application bug that won't resolve on retry, return 200 and queue the failure for internal investigation; only return 5xx for genuinely transient problems. +- **Missing \`redirect: 'error'\`-style refusal at receiver level.** If your receiver follows redirects, an attacker who can MITM the response could redirect re-tries to a malicious URL. Modern HTTP clients refuse redirects by default for POST; verify yours does. + +## Delivery debugging + +Use [\`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries) to list the recent delivery history for a webhook — every row has the response status, response body (truncated to 4 KB, only \`text/plain\` and \`application/json\` content types persisted), error message, and current state (\`pending\` / \`in_flight\` / \`delivered\` / \`failed\` / \`dead\`). + +To replay a \`dead\` or \`delivered\` delivery, call [\`POST /api/v1/webhook-deliveries/{deliveryId}/retry\`](/docs/api/reference/webhooks#post-retry). The retry creates a fresh delivery row pointing at the same payload — the original audit row stays in place. Receivers must be idempotent on the \`X-Gnubok-Delivery\` header. + +To send a synthetic test event without driving real state, call [\`POST /webhooks/{webhookId}/test\`](/docs/api/reference/webhooks#post-test). The dispatcher delivers a \`webhook.test\` event with a static payload on the next per-minute tick. + +## Auto-disable behaviour + +The dispatcher disables a webhook (sets \`active=false\` + \`disabled_reason\`) and stops attempting delivery when: + +- The receiver returns **HTTP 410 Gone** — explicit "stop sending" +- The receiver returns **HTTP 3xx redirect** — refusing to follow redirects to internal IPs is a security policy; a stable receiver should not return 3xx +- The webhook URL **resolves to a private/loopback/link-local/cloud-metadata IP** at dispatch time (DNS rebinding refusal) + +Re-enable with [\`PATCH /webhooks/{webhookId}\`](/docs/api/reference/webhooks#patch-webhooks) setting \`active: true\`. This clears \`disabled_at\` + \`disabled_reason\` but does NOT replay the deliveries that died while disabled — replay them individually with the retry endpoint. + +## Audit + retention + +Webhook delivery rows are *behandlingshistorik* (a system-event log) per BFNAR 2013:2 kap 8 § — they are immutable once they reach a terminal state (\`delivered\` or \`dead\`) so the audit trail of who-was-notified-when stays intact. The underlying *räkenskapsinformation* (the verifikation, the faktura, the AGI XML itself) lives in its own table with its own BFL 7 kap retention — webhook delivery rows are NOT räkenskapsinformation and the 7-year retention applies to the underlying record, not to the delivery envelope. + +For accounting-event delivery rows (\`journal_entry.*\`, \`period.*\`, \`salary_run.booked\`, \`agi.generated\`, \`invoice.paid\`, \`supplier_invoice.paid\`), gnubok keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy gnubok chose because the duration aligns conveniently with BFL 7 kap retention on the underlying records — it is NOT itself a statutory obligation.** The 7-year statutory retention under BFL 7 kap 1 § applies to the underlying verifikation / faktura / AGI XML in its own table, not to the delivery envelope. The integrator's own retention obligations likewise attach to the underlying records you receive (and any local copies you persist), not to the delivery-row metadata. + +Deleting a webhook does not delete its delivery history; the FK is \`ON DELETE SET NULL\` so the audit trail survives. +` diff --git a/lib/docs/markdown.tsx b/lib/docs/markdown.tsx new file mode 100644 index 00000000..4e98dac0 --- /dev/null +++ b/lib/docs/markdown.tsx @@ -0,0 +1,114 @@ +/** + * Shared Markdown renderer for the /docs/api surface. + * + * Two modes: + * - renders Markdown source as styled JSX inside a docs page. + * - getMarkdownSource() returns the raw string for the sibling .md route handlers. + * + * Stripe-inspired typography rules baked in: + * - Hedvig serif headlines (font-display) + * - Tabular nums on code, monospaced via Geist Mono + * - Hairline horizontal rules between top-level sections + * - Code blocks: paper-white surface, single-pixel border, no shadow + * - Tables: only used by the auto-generated reference; flat hairline rows + */ + +import ReactMarkdown from 'react-markdown' +import { cn } from '@/lib/utils' + +interface DocsMarkdownProps { + source: string + className?: string +} + +export function DocsMarkdown({ source, className }: DocsMarkdownProps) { + return ( +
+ ( +

{children}

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

{children}

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

{children}

+ ), + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + code: ({ className: codeClassName, children, ...props }) => { + const isBlock = (codeClassName ?? '').startsWith('language-') + if (isBlock) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + pre: ({ children }) => ( +
    +              {children}
    +            
    + ), + hr: () =>
    , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + strong: ({ children }) => {children}, + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => {children}, + }} + > + {source} +
    +
    + ) +} diff --git a/lib/docs/nav.ts b/lib/docs/nav.ts new file mode 100644 index 00000000..e685e414 --- /dev/null +++ b/lib/docs/nav.ts @@ -0,0 +1,87 @@ +/** + * Single source of truth for the /docs/api sidebar navigation. + * + * Stripe-pattern grouping: top-level sections (Getting started, Cookbooks, + * API reference, Concepts, Errors, Changelog) with nested links. Used by + * the DocsLayout sidebar AND by the landing page resource grid AND by the + * /llms-full.txt aggregator so additions land in every surface from one + * edit. + */ + +export interface DocsNavLink { + label: string + href: string + /** Optional one-line summary shown on landing-page cards. */ + summary?: string +} + +export interface DocsNavSection { + label: string + links: DocsNavLink[] +} + +export const DOCS_NAV: DocsNavSection[] = [ + { + label: 'Getting started', + links: [ + { label: 'Introduction', href: '/docs/api', summary: 'What the gnubok REST API is and how to authenticate.' }, + { label: 'Quickstart', href: '/docs/api/cookbook/quickstart', summary: 'Send your first invoice in five minutes.' }, + { label: 'Authentication', href: '/docs/api#authentication', summary: 'API keys, scopes, test mode.' }, + ], + }, + { + label: 'Cookbooks', + links: [ + { label: 'Send your first invoice', href: '/docs/api/cookbook/send-first-invoice', summary: 'Create a customer, draft an invoice, send it, mark it paid.' }, + { label: 'Ingest and categorise bank transactions', href: '/docs/api/cookbook/ingest-bank-transactions', summary: 'Push CSV/CAMT into the engine, get AI suggestions, commit.' }, + { label: 'Compute and review a VAT declaration', href: '/docs/api/cookbook/file-vat-declaration', summary: 'Compute momsdeklaration rutor 05–62 and reconcile before manual Skatteverket submission.' }, + { label: 'Run payroll and generate AGI', href: '/docs/api/cookbook/run-payroll-and-agi', summary: 'Calculate, approve, mark paid, book, generate AGI XML for manual Skatteverket upload.' }, + { label: 'Set up webhooks and verify signatures', href: '/docs/api/cookbook/webhooks', summary: 'Subscribe to events, verify HMAC, handle retries idempotently.' }, + { label: 'Year-end closing', href: '/docs/api/cookbook/year-end-closing', summary: 'Lock periods, run year-end, set opening balances.' }, + ], + }, + { + label: 'Concepts', + links: [ + { label: 'Webhooks', href: '/docs/api/webhooks', summary: 'Event types, delivery model, retries, signature verification.' }, + { label: 'Versioning', href: '/docs/api/versioning', summary: 'How API versions are pinned, upgraded, and deprecated.' }, + { label: 'Idempotency', href: '/docs/api/versioning#idempotency', summary: 'Safe retries on every write via Idempotency-Key.' }, + { label: 'Dry-run', href: '/docs/api/versioning#dry-run', summary: 'Preview every write before committing.' }, + ], + }, + { + label: 'API reference', + links: [ + { label: 'Overview', href: '/docs/api/reference', summary: 'All resources, grouped by domain.' }, + { label: 'Companies', href: '/docs/api/reference/companies' }, + { label: 'Customers', href: '/docs/api/reference/customers' }, + { label: 'Invoices', href: '/docs/api/reference/invoices' }, + { label: 'Suppliers', href: '/docs/api/reference/suppliers' }, + { label: 'Supplier invoices', href: '/docs/api/reference/supplier-invoices' }, + { label: 'Transactions', href: '/docs/api/reference/transactions' }, + { label: 'Journal entries', href: '/docs/api/reference/journal-entries' }, + { label: 'Fiscal periods', href: '/docs/api/reference/fiscal-periods' }, + { label: 'Accounts', href: '/docs/api/reference/accounts' }, + { label: 'Documents', href: '/docs/api/reference/documents' }, + { label: 'Employees', href: '/docs/api/reference/employees' }, + { label: 'Salary runs', href: '/docs/api/reference/salary-runs' }, + { label: 'Reports', href: '/docs/api/reference/reports' }, + { label: 'Imports', href: '/docs/api/reference/imports' }, + { label: 'Compliance check', href: '/docs/api/reference/compliance' }, + { label: 'Reconciliation', href: '/docs/api/reference/reconciliation' }, + { label: 'Webhooks', href: '/docs/api/reference/webhooks' }, + { label: 'Operations', href: '/docs/api/reference/operations' }, + { label: 'Voucher gap explanations', href: '/docs/api/reference/voucher-gap-explanations' }, + ], + }, + { + label: 'Reference', + links: [ + { label: 'Errors', href: '/docs/api/errors', summary: 'Every stable error code, status, and remediation.' }, + { label: 'Changelog', href: '/docs/api/changelog', summary: 'Per-version release notes.' }, + { label: 'OpenAPI 3.1 spec', href: '/api/v1/openapi.json', summary: 'Machine-readable spec for client generation.' }, + { label: 'llms.txt', href: '/llms.txt', summary: 'Agent-discoverable index.' }, + { label: 'llms-full.txt', href: '/llms-full.txt', summary: 'Full docs concatenated for LLM ingestion.' }, + ], + }, +]