Files
accounted/lib/auth/api-keys.ts
T
Mattsson 241959513b Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:49:33 +02:00

415 lines
22 KiB
TypeScript

import crypto from 'crypto'
import { createClient } from '@supabase/supabase-js'
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 kunder (1 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' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
// v1 REST API — added Phase 1
'companies:read': { label: 'Företag — läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
'events:read': { label: 'Händelser — läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
'webhooks:manage': { label: 'Webhooks — hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
'operations:read': { label: 'Operationer — läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' },
'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; 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' },
} 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',
// 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',
]
/**
* 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: '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> = {
// Transactions
gnubok_list_uncategorized_transactions: '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',
gnubok_bulk_book_transactions: 'transactions:write',
gnubok_auto_match_period: 'transactions:write',
// Customers
gnubok_list_customers: 'customers:read',
gnubok_create_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_create_invoice: 'invoices:write',
gnubok_send_invoice: 'invoices:write',
gnubok_mark_invoice_as_paid: 'invoices:write',
gnubok_mark_invoice_as_sent: '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',
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',
// Document inbox
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',
// 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_generate_agi: '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_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',
// 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',
}
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 createClient(
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 }
}
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)
}