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,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
hashRequest,
|
||||
checkIdempotencyKey,
|
||||
storeIdempotencyResponse,
|
||||
cleanupExpiredIdempotencyKeys,
|
||||
IdempotencyKeyReuseError,
|
||||
} from '../idempotency'
|
||||
|
||||
describe('hashRequest', () => {
|
||||
it('produces stable SHA-256 for the same payload', () => {
|
||||
expect(hashRequest({ a: 1, b: 'x' })).toBe(hashRequest({ a: 1, b: 'x' }))
|
||||
})
|
||||
|
||||
it('is order-independent', () => {
|
||||
expect(hashRequest({ a: 1, b: 2 })).toBe(hashRequest({ b: 2, a: 1 }))
|
||||
})
|
||||
|
||||
it('detects different values', () => {
|
||||
expect(hashRequest({ a: 1 })).not.toBe(hashRequest({ a: 2 }))
|
||||
})
|
||||
|
||||
it('handles nested objects deterministically', () => {
|
||||
const h1 = hashRequest({ outer: { x: 1, y: 2 }, list: [1, 2, 3] })
|
||||
const h2 = hashRequest({ list: [1, 2, 3], outer: { y: 2, x: 1 } })
|
||||
expect(h1).toBe(h2)
|
||||
})
|
||||
})
|
||||
|
||||
function mockClient(maybeSingleResult: { data: Record<string, unknown> | null; error: unknown }) {
|
||||
const select = vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue(maybeSingleResult),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const insert = vi.fn().mockResolvedValue({ error: null })
|
||||
const deleteFn = vi.fn().mockReturnValue({
|
||||
lt: vi.fn().mockResolvedValue({ error: null, count: 5 }),
|
||||
})
|
||||
return {
|
||||
client: {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select,
|
||||
insert,
|
||||
delete: deleteFn,
|
||||
}),
|
||||
} as never,
|
||||
select,
|
||||
insert,
|
||||
deleteFn,
|
||||
}
|
||||
}
|
||||
|
||||
describe('checkIdempotencyKey', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns null when no cached row exists', async () => {
|
||||
const { client } = mockClient({ data: null, error: null })
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns cached body when key + hash match', async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'bar' },
|
||||
expires_at: future,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toEqual({ status: 'success', body: { foo: 'bar' } })
|
||||
})
|
||||
|
||||
it('throws IdempotencyKeyReuseError on hash mismatch', async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-old',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'old' },
|
||||
expires_at: future,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
await expect(
|
||||
checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-new')
|
||||
).rejects.toBeInstanceOf(IdempotencyKeyReuseError)
|
||||
})
|
||||
|
||||
it('treats expired rows as misses', async () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString()
|
||||
const { client } = mockClient({
|
||||
data: {
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { foo: 'bar' },
|
||||
expires_at: past,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const result = await checkIdempotencyKey(client, 'user-1', 'company-1', 'key-1', 'hash-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('storeIdempotencyResponse', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('writes the response row', async () => {
|
||||
const { client, insert } = mockClient({ data: null, error: null })
|
||||
await storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', { ok: true })
|
||||
expect(insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
key: 'key-1',
|
||||
request_hash: 'hash-1',
|
||||
response_status: 'success',
|
||||
response_body: { ok: true },
|
||||
scope: 'mcp_tool',
|
||||
}))
|
||||
})
|
||||
|
||||
it('swallows duplicate-row races (23505)', async () => {
|
||||
const { client, insert } = mockClient({ data: null, error: null })
|
||||
insert.mockResolvedValueOnce({ error: { code: '23505', message: 'unique_violation' } })
|
||||
await expect(
|
||||
storeIdempotencyResponse(client, 'user-1', 'company-1', 'key-1', 'hash-1', 'success', {})
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupExpiredIdempotencyKeys', () => {
|
||||
it('returns delete count', async () => {
|
||||
const { client } = mockClient({ data: null, error: null })
|
||||
const count = await cleanupExpiredIdempotencyKeys(client)
|
||||
expect(count).toBe(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Idempotency layer for agent-safe retries.
|
||||
*
|
||||
* Use case: an agent (MCP, automation webhook, scripted client) retries an
|
||||
* operation after a network blip. Without idempotency, the retry creates a
|
||||
* duplicate side-effect — two invoices, two journal entries, two emails.
|
||||
*
|
||||
* Contract:
|
||||
* 1. The caller supplies an `idempotency_key` per logical operation.
|
||||
* 2. The server hashes the canonical request body and consults
|
||||
* idempotency_keys.
|
||||
* 3. Hit + matching hash → return cached response (suppress side-effects).
|
||||
* 4. Hit + different hash → throw IdempotencyKeyReuseError (409 in HTTP).
|
||||
* 5. Miss → proceed; on success, persist the response.
|
||||
*
|
||||
* Keys are scoped per (user, company): the same key UUID across two
|
||||
* companies cannot collide, and a multi-company user replaying a key in
|
||||
* the wrong company can never receive the other company's cached response.
|
||||
*
|
||||
* 24-hour TTL is enforced by an `expires_at` column + a cleanup cron. After
|
||||
* 24h, the same key may be reused safely — agents that retry that long after
|
||||
* the original request are not retrying, they're starting over.
|
||||
*/
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
export type IdempotencyScope = 'mcp_tool' | 'api_route'
|
||||
|
||||
export class IdempotencyKeyReuseError extends Error {
|
||||
readonly code = 'IDEMPOTENCY_KEY_REUSE'
|
||||
constructor(public readonly key: string) {
|
||||
super(`Idempotency key "${key}" was previously used with a different request body. Use a fresh key or send the original request.`)
|
||||
this.name = 'IdempotencyKeyReuseError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface IdempotencyHit {
|
||||
status: 'success' | 'error'
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical hash of a request body. Sorts keys recursively so semantically
|
||||
* identical bodies produce the same hash regardless of property order.
|
||||
*/
|
||||
export function hashRequest(body: unknown): string {
|
||||
return crypto.createHash('sha256').update(canonicalJson(body)).digest('hex')
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
|
||||
const obj = value as Record<string, unknown>
|
||||
const keys = Object.keys(obj).sort()
|
||||
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}'
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a previously-cached idempotency response.
|
||||
*
|
||||
* Returns:
|
||||
* - null when no cached entry exists (caller should proceed)
|
||||
* - the cached body when the key+hash match (caller should return it
|
||||
* without side-effects)
|
||||
*
|
||||
* Throws IdempotencyKeyReuseError when the key exists with a *different*
|
||||
* request hash — the caller is misusing the key.
|
||||
*/
|
||||
export async function checkIdempotencyKey(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
key: string,
|
||||
requestHash: string
|
||||
): Promise<IdempotencyHit | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.select('request_hash, response_status, response_body, expires_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('company_id', companyId)
|
||||
.eq('key', key)
|
||||
.maybeSingle()
|
||||
|
||||
if (error || !data) return null
|
||||
|
||||
// Expired entries are treated as misses; the cleanup cron will delete them.
|
||||
if (data.expires_at && new Date(data.expires_at) < new Date()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (data.request_hash !== requestHash) {
|
||||
throw new IdempotencyKeyReuseError(key)
|
||||
}
|
||||
|
||||
return {
|
||||
status: data.response_status as 'success' | 'error',
|
||||
body: (data.response_body ?? {}) as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the response for an idempotency key. Best-effort: a duplicate-row
|
||||
* race is swallowed so two concurrent retries don't fight over the cache.
|
||||
* The first writer wins; the second sees the unique-index conflict and skips.
|
||||
*/
|
||||
export async function storeIdempotencyResponse(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
key: string,
|
||||
requestHash: string,
|
||||
status: 'success' | 'error',
|
||||
body: Record<string, unknown>,
|
||||
scope: IdempotencyScope = 'mcp_tool'
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
key,
|
||||
request_hash: requestHash,
|
||||
scope,
|
||||
response_status: status,
|
||||
response_body: body,
|
||||
})
|
||||
|
||||
// Postgres 23505 is unique_violation — a concurrent retry already inserted.
|
||||
// Silently OK; the cached response from the winner will be returned to
|
||||
// both callers on subsequent reads.
|
||||
if (error && error.code !== '23505') {
|
||||
// Non-blocking: log but don't fail the operation. The caller already
|
||||
// succeeded; failing to persist the cache only weakens future retries.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[idempotency] failed to persist response:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep expired rows. Called by the cleanup cron.
|
||||
* Returns the number of deleted rows for logging.
|
||||
*/
|
||||
export async function cleanupExpiredIdempotencyKeys(
|
||||
supabase: SupabaseClient
|
||||
): Promise<number> {
|
||||
const { error, count } = await supabase
|
||||
.from('idempotency_keys')
|
||||
.delete({ count: 'exact' })
|
||||
.lt('expires_at', new Date().toISOString())
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Idempotency cleanup failed: ${error.message}`)
|
||||
}
|
||||
return count ?? 0
|
||||
}
|
||||
@@ -396,6 +396,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Agent auto-commit (low-risk ops staged by trusted agents)
|
||||
agent_auto_commit_enabled: z.boolean().optional(),
|
||||
agent_auto_commit_max_amount: z.number().nullable().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
|
||||
Reference in New Issue
Block a user