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:
@@ -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';
|
||||
Reference in New Issue
Block a user