Add/ai native supp (#385)

* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
This commit is contained in:
Mattsson
2026-05-04 11:12:29 +02:00
committed by GitHub
parent 5e1b0f791d
commit bb855d2ddc
46 changed files with 6507 additions and 971 deletions
@@ -0,0 +1,128 @@
-- Migration: Actor model + risk tier on pending_operations + audit_log
--
-- Adds first-class actor attribution (user vs api_key vs mcp_oauth vs cron) and
-- a risk_level for risk-tiered approval policies. This is the foundation for
-- letting trusted agents auto-commit low-risk proposals while keeping high-risk
-- operations (period close, year-end, send_invoice, etc.) gated behind human
-- approval regardless of trust level.
--
-- Why this lives here, not in app code:
-- - actor_type and risk_level are filtered/queried from the UI ("show me
-- only auto-committed actions")
-- - the same actor info needs to live in audit_log for compliance review
-- - having the columns enforced by check constraints prevents drift between
-- producers (MCP, OAuth, web, cron)
-- =============================================================================
-- 1. pending_operations: actor + risk columns
-- =============================================================================
ALTER TABLE public.pending_operations
ADD COLUMN actor_type TEXT NOT NULL DEFAULT 'user' CHECK (actor_type IN (
'user', 'api_key', 'mcp_oauth', 'cron'
)),
ADD COLUMN actor_id UUID,
ADD COLUMN actor_label TEXT,
ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'high' CHECK (risk_level IN (
'low', 'medium', 'high'
)),
ADD COLUMN auto_commit_eligible BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN auto_committed_at TIMESTAMPTZ;
-- Index supporting the "auto-committed by Claude Desktop" filter tab on the
-- pending operations page.
CREATE INDEX idx_pending_ops_actor_type ON public.pending_operations (company_id, actor_type, status);
CREATE INDEX idx_pending_ops_auto_committed ON public.pending_operations (company_id, auto_committed_at)
WHERE auto_committed_at IS NOT NULL;
-- Sanity: auto_committed_at can only be set when status = 'committed'.
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_ops_auto_commit_status CHECK (
auto_committed_at IS NULL OR status = 'committed'
);
-- =============================================================================
-- 2. audit_log: mirror actor columns
-- =============================================================================
-- audit_log already has an `actor_id` column (uuid). We add actor_type/label
-- alongside so the UI can show "Auto-committed by Claude Desktop" without
-- needing a join through api_keys.
ALTER TABLE public.audit_log
ADD COLUMN actor_type TEXT DEFAULT 'user' CHECK (actor_type IN (
'user', 'api_key', 'mcp_oauth', 'cron', 'system'
)),
ADD COLUMN actor_label TEXT;
CREATE INDEX idx_audit_log_actor_type ON public.audit_log (user_id, actor_type, created_at DESC);
-- =============================================================================
-- 3. validate_and_increment_api_key: surface api_key_id + name for actor model
-- =============================================================================
-- The RPC previously returned only (user_id, company_id, rate_limited, scopes).
-- We now also return (api_key_id, api_key_name) so the MCP server can record
-- the actor on pending_operations / audit_log without an extra round-trip.
DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text);
CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
RETURNS TABLE(
user_id uuid,
company_id uuid,
api_key_id uuid,
api_key_name text,
rate_limited boolean,
scopes text[]
)
LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
v_api_key_id uuid;
v_api_key_name text;
v_rate_limit_rpm integer;
v_request_count integer;
v_window_start timestamptz;
v_scopes text[];
BEGIN
SELECT ak.user_id, ak.company_id, ak.id, ak.name,
ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes
INTO v_user_id, v_company_id, v_api_key_id, v_api_key_name,
v_rate_limit_rpm, v_request_count, v_window_start, v_scopes
FROM public.api_keys ak
WHERE ak.key_hash = p_key_hash AND ak.revoked_at IS NULL
FOR UPDATE;
IF v_user_id IS NULL THEN
RETURN;
END IF;
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
UPDATE public.api_keys
SET request_count = 1,
rate_limit_window_start = now(),
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes;
RETURN;
END IF;
IF v_request_count >= v_rate_limit_rpm THEN
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, true, v_scopes;
RETURN;
END IF;
UPDATE public.api_keys
SET request_count = request_count + 1,
last_used_at = now()
WHERE key_hash = p_key_hash;
RETURN QUERY SELECT v_user_id, v_company_id, v_api_key_id, v_api_key_name, false, v_scopes;
END;
$$;
-- =============================================================================
-- 4. PostgREST schema reload
-- =============================================================================
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,41 @@
-- Expand pending_operations.operation_type to cover the high-leverage MCP
-- write tools added in Stream 1 Phase 1 (period close, year-end, SIE import,
-- supplier invoice approve/credit, invoice credit/convert, etc.).
--
-- These op types are high-risk and will never auto-commit (see
-- lib/pending-operations/risk-tiers.ts) — they always wait for human approval.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
-- Phase 0: original 7 op types
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
-- Stream 1 Phase 1: bookkeeping period operations
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
-- Stream 1 Phase 1: SIE import (export is read-only)
'import_sie',
-- Stream 1 Phase 1: voucher gap explanations
'explain_voucher_gap',
-- Stream 1 Phase 1: transaction reversal
'uncategorize_transaction',
-- Stream 1 Phase 1: supplier invoice lifecycle
'approve_supplier_invoice',
'credit_supplier_invoice',
-- Stream 1 Phase 1: invoice operations beyond simple create/send
'credit_invoice',
'convert_invoice'
));
@@ -0,0 +1,22 @@
-- Migration: Auto-commit settings for agent-driven pending_operations.
--
-- Lets a company opt in to letting trusted API keys auto-commit low-risk
-- proposals (e.g. create_customer) without human approval. High-risk
-- operations (period close, year-end, send_invoice, etc.) are NEVER
-- auto-committed regardless of these settings — that's enforced in
-- lib/pending-operations/should-auto-commit.ts and risk-tiers.ts, not in
-- DB config, so it can't be bypassed.
--
-- Defaults are conservative: opt-out by default, no monetary cap configured.
ALTER TABLE public.company_settings
ADD COLUMN agent_auto_commit_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN agent_auto_commit_max_amount NUMERIC(14, 2);
COMMENT ON COLUMN public.company_settings.agent_auto_commit_enabled IS
'When true, low-risk pending_operations from trusted API keys may auto-commit without human approval. High-risk ops are always gated regardless.';
COMMENT ON COLUMN public.company_settings.agent_auto_commit_max_amount IS
'Optional SEK threshold: low-risk ops above this amount still require human approval. NULL = no monetary limit beyond the risk tier check.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,51 @@
-- Migration: idempotency_keys table for safe agent retries.
--
-- Why this exists: agent-driven write operations (MCP tools, automation
-- webhooks) must be safe to retry — agents transparently re-call on
-- network blips, timeouts, or LLM-mediated retry loops. Without an
-- idempotency layer, retrying a `create_invoice` after a network blip can
-- create two invoices and double-book the revenue.
--
-- Lifecycle:
-- 1. Caller sends an `idempotency_key` (random nonce per logical operation)
-- 2. Server consults this table; on hit, returns the cached response
-- 3. On miss, server proceeds normally and stores the response
-- 4. Cleanup cron deletes rows older than 24h
--
-- The (user_id, key) pair is unique — a key is scoped to one user so an
-- agent in account A cannot collide with account B even if they reuse the
-- same UUID.
--
-- request_hash: SHA-256 of the canonical request body. Lets us detect
-- "same key, different payload" misuse and return 409 Conflict instead of
-- silently returning the cached response for a different request.
CREATE TABLE public.idempotency_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
key TEXT NOT NULL,
request_hash TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'mcp_tool', -- 'mcp_tool' | 'api_route' | future
response_status TEXT NOT NULL CHECK (response_status IN ('success', 'error')),
response_body JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + interval '24 hours'
);
-- Unique key per user — same key in different accounts cannot collide.
CREATE UNIQUE INDEX idx_idempotency_keys_user_key
ON public.idempotency_keys (user_id, key);
-- TTL index supports cleanup cron (delete where expires_at < now()).
CREATE INDEX idx_idempotency_keys_expires
ON public.idempotency_keys (expires_at);
ALTER TABLE public.idempotency_keys ENABLE ROW LEVEL SECURITY;
-- Service role writes; users can read their own rows for debugging.
CREATE POLICY "idempotency_keys_select_own" ON public.idempotency_keys
FOR SELECT USING (auth.uid() = user_id);
-- No INSERT/UPDATE/DELETE policies — writes via service role only.
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,141 @@
-- Hardening pass on the ai-native-supp branch:
-- 1. Adds a transient 'committing' status to pending_operations so the
-- commit dispatcher can claim a row atomically (CAS) before running
-- side-effects, eliminating the auto-commit / human-approval race.
-- 2. Adds a DB trigger that prevents UPDATE on pending_operations rows
-- whose previous status was 'committed' or 'rejected' — required for
-- BFL 7 kap. (räkenskapsinformation must be unalterable post-commit).
-- 3. Hardens idempotency_keys: company_id becomes NOT NULL, the unique
-- index includes company_id, and updated_at is added per CLAUDE.md
-- migration rule #2.
-- 4. Replays the schema reload that 20260430120100 forgot.
-- =============================================================================
-- 1. pending_operations: add 'committing' transient status
-- =============================================================================
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_status_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_status_check
CHECK (status IN ('pending', 'committing', 'committed', 'rejected'));
-- =============================================================================
-- 2. pending_operations: immutability after commit/rejection
-- =============================================================================
-- Once a pending_op reaches a terminal state, the params/preview/result must
-- be unchangeable. The dispatcher writes resolved_at + result_data as part of
-- the same UPDATE that flips status, so we only need to block UPDATEs whose
-- OLD.status is already terminal.
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('committed', 'rejected') THEN
RAISE EXCEPTION
'pending_operations row % is in terminal state % and cannot be modified (BFL 7 kap.)',
OLD.id, OLD.status
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pending_operations_immutability ON public.pending_operations;
CREATE TRIGGER pending_operations_immutability
BEFORE UPDATE ON public.pending_operations
FOR EACH ROW
EXECUTE FUNCTION public.enforce_pending_operations_immutability();
-- Block DELETE on terminal rows for the same reason.
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_no_delete()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('committed', 'rejected') THEN
RAISE EXCEPTION
'pending_operations row % is in terminal state % and cannot be deleted (BFL 7 kap.)',
OLD.id, OLD.status
USING ERRCODE = 'check_violation';
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pending_operations_no_delete ON public.pending_operations;
CREATE TRIGGER pending_operations_no_delete
BEFORE DELETE ON public.pending_operations
FOR EACH ROW
EXECUTE FUNCTION public.enforce_pending_operations_no_delete();
-- =============================================================================
-- 2b. pending_operations: input fields frozen at insert
-- =============================================================================
-- BFL 7 kap. requires the underlag (basis) for an affärshändelse to be
-- immutable, not just the result. The dispatcher only writes to status /
-- resolved_at / result_data after insert; params, operation_type, and
-- preview_data must never change once the row exists. Without this trigger,
-- a compromised path (or future code refactor) could rewrite the proposal
-- between staging and human approval.
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_input_frozen()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.params IS DISTINCT FROM OLD.params THEN
RAISE EXCEPTION
'pending_operations.params is frozen after insert (BFL 7 kap. underlag-immutability)'
USING ERRCODE = 'check_violation';
END IF;
IF NEW.operation_type IS DISTINCT FROM OLD.operation_type THEN
RAISE EXCEPTION
'pending_operations.operation_type is frozen after insert'
USING ERRCODE = 'check_violation';
END IF;
IF NEW.preview_data IS DISTINCT FROM OLD.preview_data THEN
RAISE EXCEPTION
'pending_operations.preview_data is frozen after insert'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pending_operations_input_frozen ON public.pending_operations;
CREATE TRIGGER pending_operations_input_frozen
BEFORE UPDATE ON public.pending_operations
FOR EACH ROW
EXECUTE FUNCTION public.enforce_pending_operations_input_frozen();
-- =============================================================================
-- 3. idempotency_keys: company_id NOT NULL + scoped unique index + updated_at
-- =============================================================================
-- The previous migration left company_id nullable and the unique index keyed
-- on (user_id, key) only. For a user owning multiple companies, replaying the
-- same idempotency_key UUID across companies could return a cached response
-- from the wrong company. Scope the cache per (user, company, key) so the
-- replay can never cross tenant boundaries.
-- Defensive: any row created before this migration with a NULL company_id is
-- ambiguous and must be cleared rather than backfilled.
DELETE FROM public.idempotency_keys WHERE company_id IS NULL;
ALTER TABLE public.idempotency_keys
ALTER COLUMN company_id SET NOT NULL;
DROP INDEX IF EXISTS idx_idempotency_keys_user_key;
CREATE UNIQUE INDEX idx_idempotency_keys_user_company_key
ON public.idempotency_keys (user_id, company_id, key);
ALTER TABLE public.idempotency_keys
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
DROP TRIGGER IF EXISTS idempotency_keys_updated_at ON public.idempotency_keys;
CREATE TRIGGER idempotency_keys_updated_at
BEFORE UPDATE ON public.idempotency_keys
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- 4. PostgREST schema reload
-- =============================================================================
-- Also covers the ALTER on pending_operations from 20260430120100, which
-- forgot to issue this notification.
NOTIFY pgrst, 'reload schema';