Files
accounted/lib/auth/api-keys.ts
T
Mattsson b6332e9ff4 Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:54:33 +02:00

461 lines
24 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_bulk_book_inbox_items: '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',
// 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_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',
// 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',
// 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_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_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_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 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)
}