From 3912c74a7b2400eeba6f49a36fdda24770c1dcbf Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 15 May 2026 15:57:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20Phase=206=20PR-2=20=E2=80=94=20doc?= =?UTF-8?q?s=20polish=20(Stripe-inspired)=20(#497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): Phase 6 PR-2 — docs polish (Stripe-inspired) Ships the developer-facing documentation surface for the v1 REST API. Mirrors Stripe's structure (landing → cookbooks → concepts → reference → errors → changelog) at /docs/api with a sticky-sidebar layout in the gnubok editorial-monochrome aesthetic. Every page is also served as plain Markdown via a sibling .md URL so agents and LLM crawlers can ingest the same content without HTML parsing — the existing /llms.txt already promised /docs/api references that this PR makes real. Single source of truth for endpoint metadata is the existing Zod registry (lib/api/v1/registry.ts). The reference pages auto-generate from it: adding a new endpoint surfaces in the docs on the next build with no manual sync. The error reference pulls directly from lib/errors/structured-errors.ts STRUCTURED_ERRORS. CONTENT LAYER (lib/docs/): - content/landing.ts — introduction, auth, base URL, response envelope, the four core principles (dry-run, idempotency, strict-mode, inline audit), pointers to every other section. - content/versioning.ts — versioning + deprecation policy (Stripe dated format), idempotency, dry-run, strict-mode write semantics, inline audit blocks. - content/webhooks.ts — webhook concept guide. Full Node.js (express + crypto) and Python (Flask + hmac) signature-verification samples that match lib/webhooks/signing.ts exactly. Lifecycle, event-type catalogue, payload shape, request headers, common pitfalls, auto-disable behaviour, audit + retention. - content/errors.ts — generated from STRUCTURED_ERRORS. Groups by domain (generic, bookkeeping, periods, invoices, supplier-invoices, transactions, reports, imports, documents, salary, company, provider). Every code is anchorable so the docs_url field on every error envelope finally points somewhere real. - content/reference.ts — generated from listEndpoints(). Groups by resource (companies, customers, invoices, suppliers, supplier-invoices, transactions, journal-entries, fiscal-periods, accounts, documents, employees, salary-runs, reports, imports, compliance, webhooks, operations, voucher-gap-explanations, reconciliation). Each endpoint section: summary, description, useWhen, doNotUseFor, pitfalls, scope, idempotent/reversible/dry-run flags, request + response examples. - content/changelog.ts — initial entry for API version 2026-05-12 covering every endpoint shipped in Phases 1-6. Lists what's coming in Phase 6 PR-3 (hardening + remaining cookbooks). - content/cookbook/quickstart.ts — five-minute send-your-first-invoice guide. Demonstrates auth, dry-run, idempotency, audit-block patterns in one continuous narrative. - content/cookbook/webhooks.ts — end-to-end webhook setup, sig verification, retry handling, idempotency on receiver side, replay patterns, auto-disable behaviour. Companion to the concept page. - content/cookbook/index.ts — recipe registry. 4 placeholder recipes (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi, year-end-closing) link to their reference pages with a "coming after Phase 6 PR-3 hardening" note. Narrative cookbook quality benefits from a focused pass after the substrate stabilises. - nav.ts — single source of truth for the sidebar nav, used by the layout AND the landing-page resource grid. - markdown.tsx — shared component using react-markdown (already a dep) with Hedvig serif headlines, Geist mono code blocks, hairline section borders, paper-white surfaces — same editorial aesthetic as the dashboard. LAYOUT (components/docs/DocsLayout.tsx): Two-column sticky-sidebar layout. Top header carries the gnubok mark + section links (API reference, Cookbooks, Errors, Changelog, openapi.json). Sidebar groups: Getting started, Cookbooks, Concepts, API reference, Reference. Active page highlighted with the same warm-beige bg the dashboard sidebar uses. ROUTES (app/docs/api/, app/llms-full.txt/): - /docs/api → landing - /docs/api/errors → error reference - /docs/api/webhooks → webhook concept - /docs/api/versioning → versioning + idempotency + dry-run - /docs/api/changelog → release notes - /docs/api/reference → resource overview - /docs/api/reference/[slug] → per-resource pages (19 resources, all generated from the registry; generateStaticParams listed) - /docs/api/cookbook/[slug] → recipe pages (8 entries, 2 fully written + 6 aliases/placeholders) - /llms-full.txt → everything concatenated for one-shot LLM ingestion Every page has a sibling .md route (e.g. /docs/api/errors.md) serving the raw Markdown for agents — same content, no HTML wrapper, same 5-min cache. Honours the existing /llms.txt promise that "every .md URL under /docs/api is served as plain Markdown". CI GUARD (lib/api/v1/__tests__/spec-snapshot.test.ts): Vitest snapshot test that locks down (a) the endpoint count, (b) the sorted set of method+path keys, (c) the set of distinct scopes referenced. CI fails if any drift unexpectedly so a Zod-schema change can't ship a silent API break — when you intentionally add/remove an endpoint, run with -u to refresh the snapshot, review the diff, and commit alongside the route change. The snapshot diff itself is a self-describing changelog entry. Initial snapshot: 100 endpoints, 17 distinct scopes, full key set sorted. Fourth assertion in the test guarantees every endpoint declares the agent-facing metadata (summary, description, useWhen, doNotUseFor, pitfalls, example) the reference pages depend on — so a registerEndpoint call that omits any of these fields is caught at CI time rather than rendering an empty section in the docs. INFRA TOUCH (lib/api/v1/load-routes.ts): Added the 5 Phase 6 webhook route imports so the registry includes them on the docs builders' path. Required for the /docs/api/reference/ webhooks page to render. The webhook routes' registerEndpoint calls already exist; this just side-effect-imports them where the spec generator can see them. Coming in Phase 6 PR-3 (hardening — separate PR): - 90-day TTL cleanup cron for non-accounting webhook deliveries - claim_due_webhook_deliveries SQL function (FOR UPDATE SKIP LOCKED) - Per-route rate limits on :test, :retry, webhook :create - V16 audit-log on webhook lifecycle events - DNS-rebinding pinned-IP HTTPS agent - Integration tests for webhook routes + *.pg.test.ts for triggers - Populated previous_attributes for update-style webhook events - The remaining 4 cookbook recipes (ingest-bank-transactions, file-vat-declaration, run-payroll-and-agi, year-end-closing) once the engine surface is fully stable. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-497 review round 1 — CI fix + 5 small docs items CI BLOCKER (the reason core-only failed): 1. **Type error on `[slug].md/route.ts` dynamic routes** — Next.js 16's route-type inference can't extract the dynamic segment from a directory whose name contains a literal suffix like `[slug].md/`. It types `params` as `Promise<{}>` and rejects our handler that declares `params: Promise<{ slug: string }>`. The framework still ROUTES requests correctly (URL `/docs/api/cookbook/quickstart.md` reaches the handler) — only the typed `params` is unusable. Fix: drop the typed `params` parameter on the two affected handlers (cookbook + reference) and parse the slug from `request.url.pathname` directly. Inline comment documents the workaround so the next person to touch these doesn't try to "fix" it back to the typed pattern. GREPTILE INLINE (2 items): 2. **Python sample was missing `import json` and `import os`** — the webhook signature-verify sample uses both but only imported `hmac`, `hashlib`, `time`, and `flask`. Added the two missing imports. 3. **`buildResourcePages()` perf — called twice per request** (P2). Each call iterates every registered endpoint, groups by resource, sorts, and serialises Markdown for all 19 resource pages. Memoised at module level — the registry is populated once at module load and immutable for the process lifetime, so a single derivation is safe to cache. Halves the cost on the HTML routes' `generateMetadata` + page render pair, and the .md route handlers (which Next.js doesn't statically pre-render) are now constant-time after the first GET. SWEDISH-COMPLIANCE PRECISION (3 items): 4. **`webhooks.ts`: behandlingshistorik vs räkenskapsinformation distinction.** The previous "Audit + retention" section conflated the two — webhook delivery rows are *behandlingshistorik* (system- event log) per BFNAR 2013:2 kap 8 §, NOT räkenskapsinformation themselves. The 7-year retention from BFL 7 kap 1 § attaches to the underlying verifikation/faktura/AGI XML in its own table, not to the delivery envelope. Updated the section to draw the distinction and clarify gnubok's 7-year retention on accounting-event delivery rows is an operational audit-trail policy, not a statutory obligation passed through to the integrator. 5. **`changelog.ts`: same distinction in the Phase 6 PR-1 entry** — replaced the "räkenskapsinformation" framing with the correct behandlingshistorik framing + the operational-policy note. 6. **Quickstart cookbook: ML 17 kap 24 § p.8 note about `beskattningsunderlag per skattesats`.** The "What just happened" section now explicitly notes that the rendered PDF contains every ML 17 kap 24 § field (including taxable amount per VAT rate) and that the JSON response's summary fields are convenience aggregates for the integration — the binding faktura content is the PDF. Forecloses the misreading that `subtotal + vat_total` is sufficient compliance. 7. **Changelog: BFL 7 kap caveat on SIE export.** The `/reports/ sie-export` line now warns that a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations — SIE captures account positions and verifikationer but lacks system documentation and behandlingshistorik. Treat as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive. Closes the misreading the swedish-sie-import-export skill flagged. DEFENSIBLE DEFERS (round 1 final): - **CM-8 SPDX-License-Identifier headers per file** (Compliance Swarm). The repo declares AGPL-3.0-or-later in the root LICENSE file, which satisfies licensing for the project as a whole. Per-file SPDX headers are a REUSE-conformance feature; we can address as a sweep across the entire codebase if/when REUSE conformance becomes a requirement. Out of scope for a docs PR. - **`/llms-full.txt` exposes payroll endpoint metadata publicly** (A.8.12). By design — the entire point of the file is one-shot LLM ingestion of the public docs corpus. Endpoint METADATA (path, scope, description) is non-sensitive; actual payroll DATA is gated behind `payroll:read` scope and requires a real API key. Adding an auth gate would defeat the agent-discovery purpose. - **Secret rotation endpoint** (Art.25(2)). Real product gap (delete + recreate is the current rotation path), but it's feature work, not docs. Tracked for Phase 6 PR-3 alongside the other webhook hardening. - **DNS rebinding pinned-IP HTTPS agent** (Art.5(1)(f)). Already documented in this changelog as a Phase 6 PR-3 item; bot is just re-flagging that it's not yet shipped. - **A.5.34 changelog cites GDPR Art.5(1)(c) for personnummer masking without linking privacy policy.** The citation is informational context for developers, not a privacy notice to data subjects. Data- subject notices live at /privacy. Adding a pointer is reasonable; adding it would consume real estate that's better spent on the technical detail. Defer. - **Compliance Swarm A.5.21 third-party attribution in llms-full.txt**. False positive — all markdown content in this PR is original first-party text. No third-party snippets to attribute. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-497 review round 2 — 6 small precision fixes All CI green after round 1 (core-only fixed). Compliance Swarm: 7 → 10 findings is the documented oscillation pattern — net-new actionable items are 6 small fixes; the rest are recurring defers (plaintext-secret variants, SPDX, planned PR-3 features the changelog already lists as "coming soon"). FIXED: 1. **Slug allow-list validation in `[slug].md` routes** (V1.2.5 ×2, medium). The cookbook + reference .md routes parse the slug from the URL pathname (round-1 workaround for Next.js 16's failed inference on `[slug].md/` directories) and pass it to a dictionary-based lookup. The lookup itself is safe — findRecipe / buildResourcePages can't reach SQL or filesystem from a bad slug — but the explicit allow-list gate keeps the contract safe if the lookup mechanism ever changes (file-load, RPC, etc.). Added `Set(COOKBOOK_SLUGS)` + `Set(RESOURCE_SLUGS)` guard before any lookup runs. 2. **Changelog: BFL 5 kap 5 § cited on both `/reverse` AND `/correct`** (swedish-compliance precision). The previous wording cited BFL 5:5 only on `/reverse` (storno) and described `/correct` as plain "rättelse" — but BFL 5:5 governs rättelse generally, and storno is the canonical method of rättelse, so both endpoints satisfy 5:5. Updated to: "/{id}/reverse (storno) and /{id}/correct (rättelse) — both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse)". 3. **Changelog AGI: explicit that XML is for manual submission** (swedish-compliance / swedish-payroll). Previously said "/generate-agi produces AGI XML" — could be misread as auto-submission to Skatteverket. Now states explicitly that the response carries `data.xml` for the integrator to upload via Skatteverket Mina Sidor (or via the optional `skatteverket` extension), and that the AGI deadline (12th / 17th of the following month) is the integrator's responsibility. Aligns with the route file's existing doNotUseFor + pitfalls metadata. 4. **Quickstart: F-skatt note qualified** (swedish-invoice-compliance). The "What just happened" section previously said the PDF "contains the F-skatt note" — only valid if the seller actually holds F-skatt. Updated to: "The 'Godkänd för F-skatt' note is included automatically when company_settings.has_f_skatt is set — confirm this on the company settings page before sending invoices in production." Also tightened the beskattningsunderlag wording to mention "one line per distinct rate on multi-rate invoices" — closes the swedish-compliance note about the multi-rate claim in the landing needing explicit support in the cookbook. 5. **Cookbook placeholder VAT description: "compute and review" not "submit"** (swedish-vat). The placeholder previously said "Compute momsdeklaration rutor and submit to Skatteverket" — but no Skatteverket-submission endpoint exists in the v1 surface; the API only computes the rutor 05–62 values for manual filing. Updated description in BOTH cookbook/index.ts AND nav.ts (where the same string was duplicated): "Compute momsdeklaration rutor 05–62 and reconcile against the GL before manual submission to Skatteverket." Title also flipped from "File a VAT declaration" to "Compute and review a VAT declaration". 6. **Cookbook nav for AGI: aligned to "generate" semantics**. nav.ts AGI summary used to say "file AGI" — same misreading risk as #3. Now: "Calculate, approve, mark paid, book, generate AGI XML for manual Skatteverket upload." DEFERS (round 2 final — every remaining swarm finding is in one of these buckets): - **🟠 Art.32 plaintext webhook secret + 4 sibling framings** (V14, V11.1, A.8.24, CC6.1). Established defer per Stripe / GitHub / Slack precedent; documented inline in lib/webhooks/signing.ts. Bot is re-flagging via the docs surface this round; underlying position unchanged. - **🟡 Art.5(1)(e) 90-day TTL non-accounting cron + V16 audit log + V2.4 rate limits**. All explicitly listed in the changelog as "Coming soon (Phase 6 PR-3 hardening)". Bot is reading the same text we wrote; not blocking. - **🟠 A.8.11 personnummer masking lacks an automated test**. Real product-hardening request, but it's feature work in the employees test surface, not docs. Tracked. - **🟡 CM-8 SPDX-License-Identifier headers per file**. Established defer from round 1 — root LICENSE covers AGPL-3.0-or-later for the project as a whole; per-file SPDX is a REUSE-conformance sweep that's its own effort. - **🟡 SR-3 SBOM dependencies**. False positive — next/server, react, next/link, next/navigation are existing project deps, not new in this PR. - **swedish-compliance "SIE disclaimer could note immutability requirement"** — the current disclaimer accurately calls out system documentation + behandlingshistorik as missing; adding immutability would over-stuff a one-line caveat. Defer with the understanding that the SIE skill itself documents the immutability requirement for any consumer that follows the reference. If round 3 plateaus (Compliance Swarm count stable, no net-new inline items), that's the merge-ready signal per Phase 4 lessons. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-497 review round 3 — 4 small precision fixes Compliance Swarm: 10 → 3 (down 70%) — net-new actionable items are the 4 below; remaining 3 swarm findings are either trivial defense- in-depth (URL decode, fixed here) or out-of-repo decisions (personnummer disclosure DPO confirmation). FIXED: 1. **URL-decode slug before allow-list check** in both .md route handlers (V1.2.5 ×2 low). The closed allow-list is pure ASCII so a percent-encoded value can't decode to a legitimate slug, but the explicit decode-then-check pattern keeps the contract correct under any future encoding-quirk runtime. try/catch around decodeURIComponent so a malformed % sequence (which throws) returns a clean 404 rather than a 500. 2. **Quickstart: explicit `delivery_date` requirement** (swedish- invoice-compliance / ML 17:24 field 7). The previous wording listed "supply date" as a covered field but didn't note that the API does NOT default delivery_date to invoice_date — integrators shipping invoices for goods delivered on a different date than the invoice date must pass delivery_date explicitly or the rendered PDF is non-compliant. Added explicit pass-it-yourself note. 3. **Quickstart: F-skatt strengthened from "verify" to "legal requirement"** (swedish-invoice-compliance / Peppol BIS 3.0 SE-R-005). The previous "confirm on settings page" wording risked integrators treating the F-skatt note as optional UX. It's a legal requirement on every faktura issued by a company that holds F-skatt registration — and a FATAL Peppol BIS 3.0 validation failure (SE-R-005) for B2G invoices when missing. Reframed as a compliance assertion: the PDF includes it automatically when the setting is true; verifying the setting is correct before production is the integrator's responsibility. 4. **Changelog: SIE post-import VAT code reconfiguration warning** (swedish-sie-import-export). The /imports/sie line previously noted the file format support but didn't warn that SIE files do NOT carry VAT codes or tax-rate-to-account mappings. After migrating from Fortnox / Visma / BL / SpeedLedger / Bokio, integrators must manually reconfigure VAT codes before the first momsdeklaration — skipping this is the most common source of incorrect VAT submissions in migrated bookkeeping. DEFERS (round 3 final — these are the architectural floor): - **🟠 A.5.34 personnummer field name + masking logic disclosed in public docs** (Compliance Swarm). Defensible: documenting that personnummer is masked is a transparency benefit (GDPR Art.13/14 intent), not a privacy disclosure risk. The DPO confirmation prompt is a reasonable governance ask but is an out-of-repo decision — the docs change is appropriate as written. - **AGI penalty amounts (625 / 1,250 SEK)**. Operational guidance for integrators building deadline-tracking; not strictly API-doc material. The deadline (12th / 17th) is documented; integrators who automate compliance can read SFL for penalties. - **VAT period thresholds in the cookbook placeholder**. Belongs in the actual cookbook content when written, not in the placeholder description. - **`invoice.credited` event-naming verification**. False positive — the emitter uses `credit_note.created` (which IS in the docs); there is no `invoice.credited` event in the codebase. Naming is consistent. - **Webhook retention sentence reordering** (swedish-compliance stylistic). Current wording leads with what delivery rows ARE (behandlingshistorik), then clarifies what they are NOT (räkenskapsinformation) — clean teaching arc, the qualifier is prominent. Reordering doesn't change clarity. Round 3 stop signal hit (per Phase 4 lessons): swarm count plateauing at the architectural floor with all remaining findings either defers, false positives, or out-of-repo decisions. Greptile has posted no inline comments since round 1's two items (both fixed). This should be merge-ready. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-497 review round 4 — 5 small precision fixes Compliance Swarm: 3 → 5 (slight uptick from oscillation, but 0 critical, 1 actionable; remaining 4 are recurring or philosophical). swedish-compliance: 7 advisories — 3 actionable precision items incorporated below; the others are forward-looking notes for cookbook content that ships in Phase 6 follow-ups. FIXED: 1. **Spec-snapshot test enforces ep.scope is explicitly defined** (CC6.3, real future-bug prevention). Previously the test asserted every endpoint declared the agent-facing metadata fields the docs depend on, but `scope` could be `undefined` — a registerEndpoint call that silently dropped the field would make the wrapper treat the route as unauthenticated. Added an assertion that `ep.scope !== undefined` (the literal sentinel `null` is allowed for genuinely public endpoints like /api/v1/health). The 4 spec tests still pass — confirming no current endpoint has undefined scope and the gate works prospectively. 2. **F-skatt: integrator responsibility for `has_f_skatt` accuracy** (swedish-invoice-compliance). The previous "verify on settings page" framing didn't connect the flag to the live Skatteverket registration. Now: "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/{id}/ 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." 3. **AGI deadline qualified by turnover** (swedish-payroll). The previous wording listed "12th / 17th of the following month" with no condition. Now: "12th of the following month for large employers, 17th for companies with annual turnover ≤ 40 MSEK." Aligns with the swedish-payroll skill's AGI filing deadline section. 4. **SIE import warning includes behandlingshistorik gap** (swedish-sie-import-export + swedish-accounting-compliance). The previous warning covered the VAT-code reconfiguration requirement but didn't note that SIE files also do NOT transfer behandlingshistorik (the source system's processing log per BFNAR 2013:2 kap 8 §) or systemdokumentation. Added: "The behandlingshistorik gap must either be preserved separately (export from the source system + archive alongside the SIE file) or accepted with documented justification — gnubok starts a fresh behandlingshistorik from the import date forward." 5. **Webhook 7-year retention: voluntary policy vs statutory obligation** (swedish-accounting-compliance). The previous wording said gnubok keeps delivery rows "for 7 years as an operational audit-trail policy" — the 7-year figure could be misread as statutory. Tightened in BOTH webhooks.ts and changelog.ts: the 7-year statutory retention under BFL 7 kap 1 § applies ONLY to the underlying verifikation/faktura/AGI XML; gnubok's 7-year policy on delivery rows is voluntary and chose the duration to align conveniently with the statutory horizon on the underlying records. DEFERS (round 4 final, all in the architectural-floor bucket): - **🟠 A.5.34 personnummer field name + masking logic disclosed in public docs** (recurring from round 3). Defensible — documenting PII handling is a transparency benefit (GDPR Art.13/14 intent), not a privacy disclosure risk. The DPO confirmation prompt is a reasonable governance ask but is an out-of-repo decision. - **🟡 A.8.23 DNS-rebinding "coming soon" item**. The bot is reading the changelog's own deferral list. Already tracked for Phase 6 PR-3 hardening. - **🟠 CC6.6 SSRF protection details exposed in /llms-full.txt**. Stripe / GitHub / Slack publish their full webhook security posture publicly (signature format, rejected IP ranges, retry policy) — documenting protections IS the trust pattern. Obscurity is not security; the SSRF protection is enforced in code, not in the docs. - **🟡 CC6.7 public CDN caching**. withPublicSecurityHeaders() already applies the appropriate headers (CSP, X-Content-Type- Options, X-Frame-Options). The 5-min cache is appropriate for static developer documentation; the alternative (no caching) is cost without security benefit since the content is intended to be public. - swedish-compliance "VAT 2026-04-01 livsmedel rate change" — forward-looking; ships when the actual VAT cookbook is written. - swedish-compliance "year-end IB/UB continuity" — forward-looking; ships when the year-end cookbook is written. - 2 verify-only notes (rättelse implementation, future salary- journal/avgifter-basis masking sweep) — not actionable in this PR. Round 4 stop signal: every remaining swarm finding is in the deferred or recurring bucket; the actionable item (CC6.3) is shipped. swedish-compliance is now in advisory mode (no errors, just stylistic suggestions and future-cookbook notes). Per Phase 4 lessons, this is the merge-ready signal. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): address PR-497 review round 5 — 4 small precision fixes (last actionable items) Compliance Swarm: 5 → 2 (down to architectural floor — 1 high + 1 medium). swedish-compliance: 7 advisories, 4 actionable precision items addressed below; the others are forward-looking notes for content that ships in Phase 6 follow-ups. Trajectory: 7 → 10 → 3 → 5 → 2. Plateaued. FIXED: 1. **Webhook secret storage guidance: secrets manager, not env file** (A.8.5 high). Added explicit instruction to the cookbook that the returned secret is signing material and must live in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) — not a plaintext .env file or config commit. Treated with the same care as a database password. 2. **AGI deadline correction: 17th = January and August only** (swedish-payroll). Round 4's wording said "12th (large employers) / 17th (≤40 MSEK)" — but per the swedish-payroll skill the 17th applies in January and August specifically, not generally to all months for sub-40 MSEK companies. Other months are the 12th regardless of employer size. Fixed to: "the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th." This would've caused integrators automating sub-40 MSEK deadline tracking to misfile by 5 days from February through July and September through December. 3. **F-skatt SE-R-005 broader scope** (swedish-invoice-compliance / swedish-e-invoicing). The previous wording framed SE-R-005 as primarily a Peppol B2G validation rule. Reframed: the F-skatt note is a legal requirement on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration — applies to PDF/paper AND Peppol/e-invoice formats. The buyer uses it to determine A-skatt withholding obligation (omitting it can shift tax liability onto the buyer); B2G is just where the validation is automated as a FATAL Peppol BIS 3.0 check. 4. **SIE behandlingshistorik gap: full räkenskapsår scope** (swedish-accounting-compliance). Round 4's wording said the integrator "must either preserve [behandlingshistorik] separately or accept the gap with documented justification" and that gnubok "starts a fresh behandlingshistorik from the import date forward." The "documented justification" framing implied the gap was acceptable as a default. Per BFNAR 2013:2 kap 8 §, the obligation attaches to the entire räkenskapsår, not from the import date. Reframed as: "must be preserved separately... 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." DEFERS (round 5 final — these are the architectural-floor items that will recur indefinitely): - **🟡 A.8.20 DNS-rebinding gap** (Compliance Swarm). Already documented in the changelog as a Phase 6 PR-3 deferral item; the bot is reading the same text we wrote. - **swedish-compliance: VAT 2026-04-01 livsmedel rate change**. Forward-looking — for the actual VAT cookbook recipe content, which ships post-Phase-6. - **swedish-compliance: year-end IB/UB continuity**. Forward-looking — same. - **swedish-compliance: SIE warning placement note**. Forward-looking — for the imports reference page when authored. - **swedish-compliance: BFNAR 2013:2 citation correct, webhook retention correct**. No-op confirmations. - **swedish-compliance: delivery_date pre-payment scenario**. Real but extremely narrow edge case (faktura utfärdad före leverans). Defer with the understanding that anyone using the API for pre-payment invoicing will read the full invoice reference, not rely solely on the quickstart. This is the merge-ready signal per Phase 4 lessons-learned: every remaining swarm finding is in the deferred or recurring bucket; swedish-compliance is in pure-advisory mode (forward-looking notes for cookbook content that ships later); CI is fully green; Greptile posted nothing past round 1's two items (both fixed). Ship it. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/docs/api.md/route.ts | 13 + app/docs/api/changelog.md/route.ts | 13 + app/docs/api/changelog/page.tsx | 17 ++ app/docs/api/cookbook/[slug].md/route.ts | 48 ++++ app/docs/api/cookbook/[slug]/page.tsx | 31 +++ app/docs/api/errors.md/route.ts | 13 + app/docs/api/errors/page.tsx | 18 ++ app/docs/api/page.tsx | 70 +++++ app/docs/api/reference.md/route.ts | 13 + app/docs/api/reference/[slug].md/route.ts | 45 ++++ app/docs/api/reference/[slug]/page.tsx | 31 +++ app/docs/api/reference/page.tsx | 17 ++ app/docs/api/versioning.md/route.ts | 13 + app/docs/api/versioning/page.tsx | 17 ++ app/docs/api/webhooks.md/route.ts | 13 + app/docs/api/webhooks/page.tsx | 17 ++ app/llms-full.txt/route.ts | 59 +++++ components/docs/DocsLayout.tsx | 110 ++++++++ .../__snapshots__/spec-snapshot.test.ts.snap | 132 ++++++++++ lib/api/v1/__tests__/spec-snapshot.test.ts | 80 ++++++ lib/api/v1/load-routes.ts | 7 + lib/docs/content/changelog.ts | 73 ++++++ lib/docs/content/cookbook/index.ts | 107 ++++++++ lib/docs/content/cookbook/quickstart.ts | 170 ++++++++++++ lib/docs/content/cookbook/webhooks.ts | 188 ++++++++++++++ lib/docs/content/errors.ts | 141 ++++++++++ lib/docs/content/landing.ts | 109 ++++++++ lib/docs/content/reference.ts | 204 +++++++++++++++ lib/docs/content/versioning.ts | 148 +++++++++++ lib/docs/content/webhooks.ts | 241 ++++++++++++++++++ lib/docs/markdown.tsx | 114 +++++++++ lib/docs/nav.ts | 87 +++++++ 32 files changed, 2359 insertions(+) create mode 100644 app/docs/api.md/route.ts create mode 100644 app/docs/api/changelog.md/route.ts create mode 100644 app/docs/api/changelog/page.tsx create mode 100644 app/docs/api/cookbook/[slug].md/route.ts create mode 100644 app/docs/api/cookbook/[slug]/page.tsx create mode 100644 app/docs/api/errors.md/route.ts create mode 100644 app/docs/api/errors/page.tsx create mode 100644 app/docs/api/page.tsx create mode 100644 app/docs/api/reference.md/route.ts create mode 100644 app/docs/api/reference/[slug].md/route.ts create mode 100644 app/docs/api/reference/[slug]/page.tsx create mode 100644 app/docs/api/reference/page.tsx create mode 100644 app/docs/api/versioning.md/route.ts create mode 100644 app/docs/api/versioning/page.tsx create mode 100644 app/docs/api/webhooks.md/route.ts create mode 100644 app/docs/api/webhooks/page.tsx create mode 100644 app/llms-full.txt/route.ts create mode 100644 components/docs/DocsLayout.tsx create mode 100644 lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap create mode 100644 lib/api/v1/__tests__/spec-snapshot.test.ts create mode 100644 lib/docs/content/changelog.ts create mode 100644 lib/docs/content/cookbook/index.ts create mode 100644 lib/docs/content/cookbook/quickstart.ts create mode 100644 lib/docs/content/cookbook/webhooks.ts create mode 100644 lib/docs/content/errors.ts create mode 100644 lib/docs/content/landing.ts create mode 100644 lib/docs/content/reference.ts create mode 100644 lib/docs/content/versioning.ts create mode 100644 lib/docs/content/webhooks.ts create mode 100644 lib/docs/markdown.tsx create mode 100644 lib/docs/nav.ts 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.' }, + ], + }, +]