f40795896f
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
529 lines
29 KiB
TypeScript
529 lines
29 KiB
TypeScript
import crypto from 'crypto'
|
|
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
|
|
const KEY_PREFIX = 'gnubok_sk_'
|
|
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
|
|
|
// ── API Key Scopes ──────────────────────────────────────────
|
|
|
|
export const API_KEY_SCOPES = {
|
|
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
|
|
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
|
|
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder (1 verktyg)' },
|
|
'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder (2 verktyg)' },
|
|
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' },
|
|
'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar (2 verktyg)' },
|
|
'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor (1 verktyg)' },
|
|
'invoices:write': { label: 'Fakturor: skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
|
|
'suppliers:read': { label: 'Leverantörer: läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
|
|
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
|
|
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
|
|
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
|
|
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' },
|
|
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
|
|
// v1 REST API: added Phase 1
|
|
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
|
'companies:write': { label: 'Företag: skriv', description: 'Uppdatera företagsinställningar via stagade verktyg eller REST-endpointen PATCH /api/v1/companies/{companyId}/settings' },
|
|
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
|
|
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
|
|
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
|
'documents:read': { label: 'Dokument: läs', description: 'Lista och hämta dokumentbilagor' },
|
|
'documents:write': { label: 'Dokument: skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
|
|
'compliance:read': { label: 'Compliance: läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' },
|
|
'skatteverket:write': { label: 'Skatteverket: skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' },
|
|
'agent:read': { label: 'Agent: läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
|
|
'agent:write': { label: 'Agent: skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' },
|
|
'pending_operations:read': { label: 'Stagade operationer: läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
|
|
'pending_operations:approve': { label: 'Stagade operationer: godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP: agenten ersätter web-UI:s granskning' },
|
|
// Reconciliation (account-keyed: bank accounts + skattekonto). Reads cover
|
|
// the account list, the bridge and the item buckets; writes cover links
|
|
// (match/unmatch) and ignore flags. Links never touch the ledger.
|
|
'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' },
|
|
'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' },
|
|
'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' },
|
|
} as const
|
|
|
|
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
|
|
|
|
export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[]
|
|
|
|
/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */
|
|
export const DEFAULT_SCOPES: ApiKeyScope[] = [
|
|
'transactions:read',
|
|
'customers:read',
|
|
'articles:read',
|
|
'invoices:read',
|
|
'suppliers:read',
|
|
'reports:read',
|
|
]
|
|
|
|
/**
|
|
* Default scope grant for OAuth-issued keys when the client did not pass an
|
|
* explicit `scope` parameter at /authorize. Read-only by design: every
|
|
* write or approval scope must be requested explicitly by the client AND
|
|
* affirmatively ticked by the user on the consent screen.
|
|
*
|
|
* Rationale (do not weaken without a documented security decision):
|
|
* - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary
|
|
* access set must be the silent baseline.
|
|
* - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities
|
|
* (write, approve) must not be bundled into a default grant.
|
|
* - Segregation of Duties (findStageApproveConflict below): granting any
|
|
* STAGING_SCOPES member together with `pending_operations:approve` on a
|
|
* single key lets an automated agent both stage AND commit financial
|
|
* postings without a human-in-the-loop review. Keeping the default
|
|
* read-only prevents this combination from being silently issued.
|
|
* - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that
|
|
* create or modify verifikationer must be opt-in at the authorization
|
|
* layer; conversational acknowledgement at the agent layer is not an
|
|
* auditable substitute.
|
|
*/
|
|
export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [
|
|
'transactions:read',
|
|
'customers:read',
|
|
'articles:read',
|
|
'invoices:read',
|
|
'suppliers:read',
|
|
'reports:read',
|
|
'companies:read',
|
|
'events:read',
|
|
'operations:read',
|
|
'documents:read',
|
|
'compliance:read',
|
|
'payroll:read',
|
|
'pending_operations:read',
|
|
]
|
|
|
|
/**
|
|
* Scopes advertised in the RFC 8414 authorization-server metadata document
|
|
* (/.well-known/oauth-authorization-server). Restricted to the same set that
|
|
* /authorize will grant by default: destructive scopes still work when
|
|
* requested explicitly, they just aren't enumerated for unauthenticated
|
|
* callers (defense-in-depth against scope-escalation reconnaissance).
|
|
*/
|
|
export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES]
|
|
|
|
/**
|
|
* Scopes that allow staging a pending_operation. Used to detect a
|
|
* segregation-of-duties conflict when paired with `pending_operations:approve`
|
|
* on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1).
|
|
*
|
|
* Documented system control (BFNAR 2013:2 systemdokumentation): `agent:write`
|
|
* is deliberately NOT a staging scope. The memory tools it gates
|
|
* (gnubok_remember_fact/forget_fact) write advisory agent context: they
|
|
* cannot create, mutate, or stage räkenskapsinformation, so memory-write +
|
|
* approve on one key does not let an agent both stage and commit bookkeeping.
|
|
* If a future memory surface ever feeds DIRECTLY into voucher generation
|
|
* (rather than via a separately staged-and-approved operation), revisit this
|
|
* classification.
|
|
*/
|
|
export const STAGING_SCOPES: ApiKeyScope[] = [
|
|
'transactions:write',
|
|
'customers:write',
|
|
'articles:write',
|
|
'invoices:write',
|
|
'suppliers:write',
|
|
'bookkeeping:write',
|
|
'payroll:write',
|
|
'documents:write',
|
|
'companies:write',
|
|
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
|
|
// key holding both this and pending_operations:approve is a SoD conflict:
|
|
// findStageApproveConflict picks it up automatically from this list.
|
|
'skatteverket:write',
|
|
// gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_*
|
|
// operations; same SoD reasoning.
|
|
'reconciliation:write',
|
|
// gnubok_reconcile_signoff stages reconciliation_signoff.
|
|
'reconciliation:signoff',
|
|
]
|
|
|
|
/**
|
|
* Detect a segregation-of-duties conflict between staging and approval scopes
|
|
* on the same key. Returns the offending staging scope, or null when the
|
|
* combination is clean. Callers may choose to block, warn, or record an
|
|
* acknowledged risk acceptance.
|
|
*
|
|
* Granting both stage+approve to the same actor lets an automated agent both
|
|
* stage AND commit financial postings without a human-in-the-loop review,
|
|
* which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik)
|
|
* and BFL 5 kap 5§ traceability requirements.
|
|
*/
|
|
export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null {
|
|
if (!scopes.includes('pending_operations:approve')) return null
|
|
return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null
|
|
}
|
|
|
|
/** Scope domain groups for UI rendering */
|
|
export const SCOPE_GROUPS = [
|
|
{ domain: 'companies', label: 'Företag', read: 'companies:read' as const, write: 'companies:write' as const },
|
|
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
|
|
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
|
|
{ domain: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles:write' as const },
|
|
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
|
|
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const },
|
|
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
|
|
{ domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const },
|
|
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
|
{ domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const },
|
|
{ domain: 'agent', label: 'Agent', read: 'agent:read' as const, write: 'agent:write' as const },
|
|
{ domain: 'skatteverket', label: 'Skatteverket', read: null, write: 'skatteverket:write' as const },
|
|
] as const
|
|
|
|
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
|
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
|
// Companies
|
|
gnubok_list_companies: 'companies:read',
|
|
gnubok_get_company_settings: 'companies:read',
|
|
gnubok_update_company_settings: 'companies:write',
|
|
// Transactions
|
|
gnubok_list_uncategorized_transactions: 'transactions:read',
|
|
gnubok_list_cash_accounts: 'transactions:read',
|
|
gnubok_list_transactions_without_documents: 'transactions:read',
|
|
gnubok_create_transactions: 'transactions:write',
|
|
gnubok_categorize_transaction: 'transactions:write',
|
|
gnubok_receipt_matcher: 'transactions:write',
|
|
gnubok_get_counterparty_templates: 'transactions:read',
|
|
gnubok_suggest_categories: 'transactions:read',
|
|
gnubok_match_transaction_to_invoice: 'transactions:write',
|
|
gnubok_link_transaction_to_journal_entry: 'transactions:write',
|
|
gnubok_match_batch_allocate: 'transactions:write',
|
|
// Reconciliation (account-keyed). gnubok_get_reconciliation_status keeps its
|
|
// historical reports:read so existing keys are not cut off.
|
|
gnubok_list_reconciliation_items: 'reconciliation:read',
|
|
gnubok_reconcile_match: 'reconciliation:write',
|
|
gnubok_reconcile_unmatch: 'reconciliation:write',
|
|
gnubok_reconcile_signoff: 'reconciliation:signoff',
|
|
gnubok_bulk_book_transactions: 'transactions:write',
|
|
gnubok_bulk_book_inbox_items: 'transactions:write',
|
|
gnubok_auto_match_period: 'transactions:write',
|
|
// Customers
|
|
gnubok_list_customers: 'customers:read',
|
|
gnubok_create_customer: 'customers:write',
|
|
gnubok_update_customer: 'customers:write',
|
|
// Articles (artikelregister)
|
|
gnubok_list_articles: 'articles:read',
|
|
gnubok_create_article: 'articles:write',
|
|
gnubok_update_article: 'articles:write',
|
|
// Invoices
|
|
gnubok_list_invoices: 'invoices:read',
|
|
gnubok_get_invoice_deliveries: 'invoices:read',
|
|
gnubok_create_invoice: 'invoices:write',
|
|
gnubok_update_invoice: 'invoices:write',
|
|
gnubok_send_invoice: 'invoices:write',
|
|
gnubok_mark_invoice_as_paid: 'invoices:write',
|
|
gnubok_mark_invoice_as_sent: 'invoices:write',
|
|
// Recurring invoice schedules (staged template writes; no send/book at commit)
|
|
gnubok_list_recurring_schedules: 'invoices:read',
|
|
gnubok_create_recurring_schedule: 'invoices:write',
|
|
gnubok_update_recurring_schedule: 'invoices:write',
|
|
// Suppliers
|
|
gnubok_list_suppliers: 'suppliers:read',
|
|
gnubok_list_supplier_invoices: 'suppliers:read',
|
|
// Reports
|
|
gnubok_get_trial_balance: 'reports:read',
|
|
gnubok_get_vat_report: 'reports:read',
|
|
gnubok_vat_review_widget: 'reports:read',
|
|
gnubok_vat_close_check: 'reports:read',
|
|
gnubok_get_kpi_report: 'reports:read',
|
|
gnubok_get_income_statement: 'reports:read',
|
|
gnubok_list_accounts: 'reports:read',
|
|
// Kontoplan management: staged reference-data writes
|
|
gnubok_create_account: 'bookkeeping:write',
|
|
gnubok_update_account: 'bookkeeping:write',
|
|
// Verifikat annotation (notes-only edit: allowed on posted entries)
|
|
gnubok_set_voucher_note: 'bookkeeping:write',
|
|
gnubok_get_balance_sheet: 'reports:read',
|
|
gnubok_get_general_ledger: 'reports:read',
|
|
gnubok_query_journal: 'reports:read',
|
|
gnubok_get_ar_ledger: 'reports:read',
|
|
gnubok_get_supplier_ledger: 'reports:read',
|
|
gnubok_list_fiscal_periods: 'reports:read',
|
|
gnubok_get_reconciliation_status: 'reports:read',
|
|
gnubok_list_accrual_schedules: 'reports:read',
|
|
// Dimensions (kostnadsställe/projekt) registry: reads next to the report
|
|
// tools; the staged value-create is a bookkeeping write (dimensions PR3).
|
|
gnubok_list_dimensions: 'reports:read',
|
|
gnubok_list_dimension_values: 'reports:read',
|
|
gnubok_create_dimension_value: 'bookkeeping:write',
|
|
gnubok_get_dimension_pnl: 'reports:read',
|
|
// Staged bulk retag of posted-line dimensions (dimensions PR6).
|
|
gnubok_tag_journal_lines: 'bookkeeping:write',
|
|
// Document inbox
|
|
gnubok_create_document_upload: 'transactions:write',
|
|
gnubok_complete_document_upload: 'transactions:write',
|
|
gnubok_upload_document: 'transactions:write',
|
|
gnubok_list_inbox_items: 'transactions:read',
|
|
gnubok_get_inbox_item: 'transactions:read',
|
|
gnubok_list_unmatched_documents: 'transactions:read',
|
|
gnubok_get_document_content: 'transactions:read',
|
|
gnubok_attach_document_to_transaction: 'transactions:write',
|
|
gnubok_link_document_to_voucher: 'bookkeeping:write',
|
|
gnubok_link_documents_to_vouchers: 'bookkeeping:write',
|
|
// Körjournal (mileage): trip log reads/writes are payroll surface
|
|
// (milersättning, 7331); booking the verifikat is a journal write.
|
|
gnubok_list_mileage_trips: 'payroll:read',
|
|
gnubok_log_mileage_trip: 'payroll:write',
|
|
gnubok_book_mileage_period: 'bookkeeping:write',
|
|
// Payroll
|
|
gnubok_list_employees: 'payroll:read',
|
|
gnubok_get_salary_run: 'payroll:read',
|
|
gnubok_get_salary_journal: 'payroll:read',
|
|
gnubok_create_salary_run: 'payroll:write',
|
|
gnubok_calculate_salary_run: 'payroll:write',
|
|
gnubok_book_salary_run: 'payroll:write',
|
|
gnubok_generate_agi: 'payroll:write',
|
|
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
|
|
gnubok_get_employee: 'payroll:read',
|
|
gnubok_get_payslip: 'payroll:read',
|
|
gnubok_list_absence: 'payroll:read',
|
|
gnubok_update_payslip_line: 'payroll:write',
|
|
gnubok_register_absence: 'payroll:write',
|
|
gnubok_delete_absence: 'payroll:write',
|
|
gnubok_create_employee: 'payroll:write',
|
|
gnubok_update_employee: 'payroll:write',
|
|
gnubok_set_employee_opening_balances: 'payroll:write',
|
|
gnubok_get_vacation_balance: 'payroll:read',
|
|
gnubok_close_vacation_year: 'payroll:write',
|
|
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
|
|
gnubok_close_period: 'bookkeeping:write',
|
|
gnubok_lock_period: 'bookkeeping:write',
|
|
gnubok_unlock_period: 'bookkeeping:write',
|
|
gnubok_run_year_end: 'bookkeeping:write',
|
|
gnubok_post_kontantmetod_cutoff: 'bookkeeping:write',
|
|
gnubok_year_end_readiness: 'reports:read',
|
|
gnubok_set_opening_balances: 'bookkeeping:write',
|
|
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
|
gnubok_explain_voucher_gap: 'bookkeeping:write',
|
|
gnubok_list_voucher_gaps: 'reports:read',
|
|
// Transaction reversal (medium-risk)
|
|
gnubok_uncategorize_transaction: 'transactions:write',
|
|
// SIE export (read-only) + import (write)
|
|
gnubok_export_sie: 'reports:read',
|
|
gnubok_audit_package: 'reports:read',
|
|
gnubok_import_sie: 'bookkeeping:write',
|
|
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
|
gnubok_generate_rot_rut_file: 'invoices:write',
|
|
// Supplier CRUD
|
|
gnubok_create_supplier: 'suppliers:write',
|
|
// Supplier invoice lifecycle
|
|
gnubok_approve_supplier_invoice: 'suppliers:write',
|
|
gnubok_credit_supplier_invoice: 'suppliers:write',
|
|
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
|
|
gnubok_set_inbox_extracted_data: 'suppliers:write',
|
|
// Supplier invoice payment via existing verifikat (no new bokföring)
|
|
gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read',
|
|
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
|
|
// Invoice conversion + crediting
|
|
gnubok_convert_invoice: 'invoices:write',
|
|
gnubok_credit_invoice: 'invoices:write',
|
|
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
|
|
gnubok_create_voucher: 'bookkeeping:write',
|
|
gnubok_correct_entry: 'bookkeeping:write',
|
|
gnubok_reverse_journal_entry: 'bookkeeping:write',
|
|
// Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific
|
|
// profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill
|
|
// stay unscoped (discovery + static Markdown bodies + globally-readable atom
|
|
// registry: no per-company data).
|
|
gnubok_get_agent_briefing: 'agent:read',
|
|
// Agent memory write (previously UNMAPPED → callable by any key). Mapping to
|
|
// agent:write; existing non-revoked keys are grandfathered in the
|
|
// 20260619140000 migration so this does not regress them.
|
|
gnubok_remember_fact: 'agent:write',
|
|
gnubok_forget_fact: 'agent:write',
|
|
// Pending operations approval (mirrors the /pending web UI)
|
|
gnubok_list_pending_operations: 'pending_operations:read',
|
|
gnubok_approve_pending_operation: 'pending_operations:approve',
|
|
gnubok_reject_pending_operation: 'pending_operations:approve',
|
|
// Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI);
|
|
// the two submit tools require the opt-in skatteverket:write staging scope.
|
|
gnubok_vat_declaration_validate: 'compliance:read',
|
|
gnubok_vat_declaration_status: 'compliance:read',
|
|
gnubok_agi_status: 'compliance:read',
|
|
gnubok_vat_declaration_submit: 'skatteverket:write',
|
|
gnubok_agi_submit: 'skatteverket:write',
|
|
|
|
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
|
|
// These tools shipped without a scope mapping, making them callable by ANY
|
|
// authenticated key. Mapping them is accept-the-break by decision
|
|
// (2026-07-13): keys that relied on the default-allow hole lose access
|
|
// until granted the proper scope. Release-note callout required for the
|
|
// four WRITES below.
|
|
gnubok_link_invoice_to_voucher: 'invoices:write',
|
|
gnubok_undo_sie_import: 'bookkeeping:write',
|
|
gnubok_post_annual_depreciation: 'bookkeeping:write',
|
|
gnubok_import_rot_rut_beslut: 'invoices:write',
|
|
gnubok_list_verifikat_without_documents: 'transactions:read',
|
|
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
|
|
gnubok_propose_dispositioner: 'reports:read',
|
|
gnubok_propose_accruals: 'reports:read',
|
|
gnubok_propose_annual_depreciation: 'reports:read',
|
|
gnubok_preview_arsredovisning: 'reports:read',
|
|
gnubok_validate_arsredovisning: 'reports:read',
|
|
gnubok_list_arsredovisning_versions: 'reports:read',
|
|
gnubok_get_arsredovisning_filing_status: 'reports:read',
|
|
gnubok_preview_ef_declaration: 'reports:read',
|
|
// Deliberately UNSCOPED (available to any authenticated key):
|
|
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
|
|
// gnubok_feedback. Discovery + static skill bodies + feedback channel
|
|
// carry no per-company data; keeping them open is what lets an agent
|
|
// orient itself before its key's scopes are known.
|
|
}
|
|
|
|
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
|
if (scopes === null || scopes === undefined) return null
|
|
if (!Array.isArray(scopes)) return null
|
|
const valid = scopes.filter((s): s is ApiKeyScope => s in API_KEY_SCOPES)
|
|
return valid.length > 0 ? valid : null
|
|
}
|
|
|
|
/**
|
|
* Create a Supabase service client that doesn't require cookies.
|
|
* Used for API key validation (MCP, webhooks) where there's no browser session.
|
|
*/
|
|
export function createServiceClientNoCookies() {
|
|
return createServiceRoleClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
|
|
export function generateApiKey(mode: ApiKeyMode = 'live'): { key: string; hash: string; prefix: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
// Test keys carry an explicit `test_` infix so integrators can tell at a
|
|
// glance which environment a key targets (matches the llms.txt contract:
|
|
// `gnubok_sk_test_<random>`). The infix is purely cosmetic: the authoritative
|
|
// mode is the `mode` column on api_keys, read back by hash in validateApiKey,
|
|
// so nothing trusts the key string. Both variants keep the `gnubok_sk_`
|
|
// prefix so the `startsWith(KEY_PREFIX)` check in validateApiKey still holds.
|
|
const key = mode === 'test' ? `${KEY_PREFIX}test_${random}` : `${KEY_PREFIX}${random}`
|
|
const hash = hashApiKey(key)
|
|
// First 18 chars: 'gnubok_sk_test_xyz' for test keys, 'gnubok_sk_xxxxxxxx'
|
|
// for live: the stored prefix is what the settings UI shows, so the test_
|
|
// infix is visible in the key list without exposing the secret.
|
|
const prefix = key.slice(0, KEY_PREFIX.length + 8)
|
|
return { key, hash, prefix }
|
|
}
|
|
|
|
/**
|
|
* SHA-256, deliberately, and NOT a slow KDF like bcrypt/argon2.
|
|
*
|
|
* CodeQL flags this as js/insufficient-password-hash. That rule exists for
|
|
* user-chosen passwords, which are low-entropy and brute-forceable, so the
|
|
* defence is to make each guess expensive. This input is not a password: keys
|
|
* come from generateApiKey as 32 CSPRNG bytes (`gnubok_sk_<base64url>`), and no
|
|
* work factor moves the needle on a 256-bit random secret.
|
|
*
|
|
* A slow KDF would also be actively worse here: this runs on the hot path of
|
|
* every MCP request, where the hash is the primary-key lookup used to find the
|
|
* row, so per-request cost is real latency for zero security gain.
|
|
*
|
|
* Do NOT "fix" this by changing the algorithm. The hash IS the stored
|
|
* credential, so a different function invalidates every live `gnubok_sk_` key,
|
|
* breaking existing MCP connections with no migration path.
|
|
*/
|
|
export function hashApiKey(key: string): string {
|
|
return crypto.createHash('sha256').update(key).digest('hex')
|
|
}
|
|
|
|
export function generateRefreshToken(): { token: string; hash: string } {
|
|
const random = crypto.randomBytes(32).toString('base64url')
|
|
const token = `${REFRESH_TOKEN_PREFIX}${random}`
|
|
const hash = crypto.createHash('sha256').update(token).digest('hex')
|
|
return { token, hash }
|
|
}
|
|
|
|
export function hashRefreshToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex')
|
|
}
|
|
|
|
export function isRefreshToken(token: string): boolean {
|
|
return token.startsWith(REFRESH_TOKEN_PREFIX)
|
|
}
|
|
|
|
export function extractBearerToken(request: Request): string | null {
|
|
const authHeader = request.headers.get('authorization')
|
|
if (!authHeader?.startsWith('Bearer ')) return null
|
|
return authHeader.slice(7)
|
|
}
|
|
|
|
/**
|
|
* Validate an API key and enforce rate limiting.
|
|
* Uses the DB RPC for atomic check + increment.
|
|
* Returns the user_id, company_id, api_key_id, name, and effective scopes on
|
|
* success, or an error with HTTP status.
|
|
* null scopes in DB → DEFAULT_SCOPES (read-only).
|
|
*
|
|
* api_key_id and api_key_name are returned so callers (e.g. the MCP server)
|
|
* can record actor attribution on pending_operations and audit_log.
|
|
* They may be undefined when the deployed DB hasn't yet run the migration
|
|
* that adds them to the RPC return shape.
|
|
*/
|
|
/**
|
|
* Operating mode of the API key. 'live' keys see real company data; 'test' keys
|
|
* are bound to deterministic sandbox companies. Keys created before the Phase 1
|
|
* migration default to 'live' for backwards compatibility.
|
|
*/
|
|
export type ApiKeyMode = 'live' | 'test'
|
|
|
|
export async function validateApiKey(
|
|
key: string
|
|
): Promise<
|
|
| {
|
|
userId: string
|
|
companyId: string
|
|
apiKeyId?: string
|
|
apiKeyName?: string
|
|
scopes: ApiKeyScope[]
|
|
mode: ApiKeyMode
|
|
}
|
|
| { error: string; status: number }
|
|
> {
|
|
if (isRefreshToken(key)) {
|
|
return {
|
|
error: 'Refresh token cannot be used as access token; exchange it at /api/mcp-oauth/token',
|
|
status: 401,
|
|
}
|
|
}
|
|
|
|
if (!key.startsWith(KEY_PREFIX)) {
|
|
return { error: 'Invalid API key format', status: 401 }
|
|
}
|
|
|
|
const hash = hashApiKey(key)
|
|
const supabase = createServiceClientNoCookies()
|
|
|
|
const { data, error } = await supabase.rpc('validate_and_increment_api_key', {
|
|
p_key_hash: hash,
|
|
})
|
|
|
|
if (error || !data || data.length === 0) {
|
|
return { error: 'Invalid API key', status: 401 }
|
|
}
|
|
|
|
const row = data[0]
|
|
|
|
if (row.rate_limited) {
|
|
return { error: 'Rate limit exceeded', status: 429 }
|
|
}
|
|
|
|
return {
|
|
userId: row.user_id,
|
|
companyId: row.company_id,
|
|
apiKeyId: row.api_key_id,
|
|
apiKeyName: row.api_key_name,
|
|
scopes: validateScopes(row.scopes) ?? DEFAULT_SCOPES,
|
|
// `mode` may be undefined when the deployed DB hasn't yet run the Phase 1
|
|
// migration that adds it to the RPC return. Default to 'live' so existing
|
|
// keys behave unchanged.
|
|
mode: (row.mode === 'test' ? 'test' : 'live') as ApiKeyMode,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a given scope is allowed by the key's scopes.
|
|
*/
|
|
export function hasScope(keyScopes: ApiKeyScope[], required: ApiKeyScope): boolean {
|
|
return keyScopes.includes(required)
|
|
}
|