Files
accounted/lib/api/idempotency.ts
T
Mattsson bb855d2ddc 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
2026-05-04 11:12:29 +02:00

156 lines
5.2 KiB
TypeScript

/**
* 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
}