feat(agents): per-key approval authority, the amount an agent may post unattended (#2079)
* feat(agents): per-key approval authority, the amount an agent may post unattended An API key gets an optional ceiling in SEK. Above it the agent may still stage the work, it just may not finish it alone: a human approves the same verifikat in the app. Default is NULL, so every existing key keeps its behaviour and turning this on is entirely opt-in. Enforced at the two places an API key reaches the ledger, and at both the refusal happens BEFORE the point of no return: - MCP: in commitPendingOperation, before the atomic claim, so the operation stays 'pending'. Behind the claim it would be caught by the generic handler, marked terminal 'rejected', and the staged verifikat would be gone. - REST: in journal-entries.commit, before commitEntry, so the draft stays a draft and the voucher sequence never advances (BFL 5 kap. 7 §). The dry run refuses too, rather than promising a voucher number the key cannot deliver. Not enforced inside commit_journal_entry: a RAISE there is swallowed by engine.ts into a retryable 500, and it would cost a DROP+CREATE on the function that issues every voucher number. Operations whose amount is only known during dispatch (batch allocation, bulk booking, the settlement link paths) fail OPEN behind an explicit allowlist. Pricing them ahead of dispatch would be a guess, and a wrong guess silently breaks batch allocation the day someone sets a limit. The allowlist is derived from what production actually stores: create_voucher carries total_debit on 1389 of 1389 rows, categorize_transaction carries amount on 2002 of 2003, create_supplier_invoice_from_inbox carries total on 208 of 228. This is a blast-radius cap, not a security boundary. A per-entry ceiling is defeated by splitting one entry into several, and an LLM will find that, so UNATTENDED_COMMIT_LIMIT_EXCEEDED forbids splitting first: one affärshändelse is one verifikat (BFL 5 kap. 6 §). A cumulative rolling-window limit is the primitive that actually bounds exposure and is left to a separate change. The guard is written NULL-first everywhere. An absent, unparseable or non-positive ceiling always means unlimited, never "block everything". Agents read their own ceiling from gnubok_get_agent_briefing instead of discovering it by burning a staged verifikat on a 403. Changing a ceiling is auditable: it now renders in behandlingshistorik (BFL 5 kap. 11 §). The audit trigger already fired on the column, but the report dropped the event because the field was not in its diff map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(skill): regenerate accounted-api skill for the new commit pitfall apiskill:check is a ratchet: the generated reference must match the endpoint registry. Never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(agents): pin the DB default itself, and declare the briefing field required Two review findings, both real: - the default test stored an explicit NULL, so it stayed green even if the column default changed to a positive ceiling: the one change that would silently start blocking every existing key. It now omits the column. - gnubok_get_agent_briefing documents unattended_commit_limit as always present and emits it unconditionally, so it belongs in the output schema's required list. Declined the NOT VALID constraint suggestion, with the reason recorded in the migration: api_keys is 388 rows / 768 kB in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(agents): name the TOCTOU window in the REST ceiling check A security scan flagged that the line sum is read before commitEntry, so a concurrent write to the draft's lines can post over the ceiling. Real, and accepted: closing it means enforcing inside commit_journal_entry, where a RAISE becomes a retryable 500 and destroys the staged operation on the MCP path. Recorded in the code rather than left implicit, so nobody later mistakes this for a hard control. A per-entry ceiling is already defeated by splitting, which needs no race; the cumulative rolling-window limit is the primitive that bounds exposure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agents): price the settlement and batch paths that were bypassing the ceiling A security scan flagged that known money-posting operations fail open, and it was right. The first cut priced only create_voucher, categorize_transaction and create_supplier_invoice_from_inbox, on the belief that the batch and settlement paths computed their totals only inside SQL at dispatch. Production says otherwise: the staged preview already carries the amount, because it is the number a human is shown when approving the operation. Over the last 120 days each of these is present and numeric on 100% of that type's staged rows: link_transaction_journal_entry transaction_amount 1369 rows bulk_book_transactions tx_sum 273 rows link_supplier_invoice_voucher payment_amount 55 rows match_batch_allocate total_allocated 24 rows mark_invoice_paid total 3 rows So a key with a ceiling could post any amount through the four largest settlement paths. Now priced, and the ceiling applies. Only reconciliation_match stays unpriced: it carries pair_count, which is a COUNT. Pricing off that would compare pairs against kronor, which is worse than not enforcing. link_document_to_voucher and attach_document_to_transaction move no money at all; the transaction_amount they carry is context, not a posting. Genuinely unpriceable types still fail OPEN. This control can only ever narrow what a key does, and a wrong guess at an amount blocks a legitimate commit, so guessing high would leave an agent unable to work. Adds a test that walks the whole allowlist, so a typo'd field name cannot silently make a type unpriceable again: that is exactly the hole this closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(mcp): drop the ceiling from the agent briefing, the payload budget has no room The tools/list context-budget bench sits at 65 000 tokens and main now leaves roughly 20 tokens of headroom. An always-present field on the briefing's output schema costs about 85, so this addition alone pushed the bench red. The bench's own note is explicit that the answer is to demote a tool rather than raise the ceiling, so raising it here would be the wrong trade for a nice-to-have. Nothing is lost that matters: the operation is never destroyed when it is refused, so discovering the ceiling from UNATTENDED_COMMIT_LIMIT_EXCEEDED costs one round trip and no work. That error already carries both attempted and limit, and GET /api/settings/api-keys returns the value. Re-exposing it on the briefing is worth doing once there is budget to spend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(api): spell affärshändelse correctly in the commit pitfall Fixed in the route's registerEndpoint pitfalls, which is the source; the skill reference is regenerated from it and never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
2814d70cb4
commit
9fe37b85b5
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const mockSupabase = {
|
||||
auth: { getUser: vi.fn() },
|
||||
from: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const getActiveCompanyIdMock = vi.fn()
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: (...args: unknown[]) => getActiveCompanyIdMock(...args),
|
||||
}))
|
||||
|
||||
const requireWritePermissionMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
|
||||
}))
|
||||
|
||||
import { PATCH } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const params = { params: Promise.resolve({ id: 'key-1' }) }
|
||||
|
||||
/**
|
||||
* Chainable proxy over .update().eq().eq().is().select().maybeSingle().
|
||||
* Records the update payload and every .eq()/.is() filter, so the tests can
|
||||
* assert on tenant scoping rather than trusting it.
|
||||
*/
|
||||
function setupFrom(result: { data?: unknown; error?: unknown }) {
|
||||
const updateSpy = vi.fn()
|
||||
const filters: Array<[string, unknown]> = []
|
||||
|
||||
mockSupabase.from.mockImplementation(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_t, prop) {
|
||||
if (prop === 'update') {
|
||||
return (payload: unknown) => {
|
||||
updateSpy(payload)
|
||||
return new Proxy(chain, handler)
|
||||
}
|
||||
}
|
||||
if (prop === 'eq' || prop === 'is') {
|
||||
return (col: string, val: unknown) => {
|
||||
filters.push([col, val])
|
||||
return new Proxy(chain, handler)
|
||||
}
|
||||
}
|
||||
if (prop === 'maybeSingle' || prop === 'single') {
|
||||
return () =>
|
||||
Promise.resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
return () => new Proxy(chain, handler)
|
||||
},
|
||||
}
|
||||
return new Proxy(chain, handler)
|
||||
})
|
||||
|
||||
return { updateSpy, filters }
|
||||
}
|
||||
|
||||
function patch(body: unknown) {
|
||||
return createMockRequest('/api/settings/api-keys/key-1', { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
getActiveCompanyIdMock.mockResolvedValue('company-1')
|
||||
requireWritePermissionMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('PATCH /api/settings/api-keys/[id]', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const res = await PATCH(patch({ unattended_commit_limit: 5000 }), params)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('sets a ceiling and scopes the update to the active company and unrevoked keys', async () => {
|
||||
const { updateSpy, filters } = setupFrom({
|
||||
data: { id: 'key-1', unattended_commit_limit: 5000 },
|
||||
})
|
||||
const res = await PATCH(patch({ unattended_commit_limit: 5000 }), params)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { unattended_commit_limit: number }
|
||||
}>(res)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.unattended_commit_limit).toBe(5000)
|
||||
expect(updateSpy).toHaveBeenCalledWith({ unattended_commit_limit: 5000 })
|
||||
// Tenant scoping is the whole security story of this route: without the
|
||||
// company_id filter, any authenticated user could raise any key's
|
||||
// authority by guessing an id.
|
||||
expect(filters).toContainEqual(['company_id', 'company-1'])
|
||||
expect(filters).toContainEqual(['id', 'key-1'])
|
||||
expect(filters).toContainEqual(['revoked_at', null])
|
||||
})
|
||||
|
||||
it('accepts null to clear the ceiling', async () => {
|
||||
const { updateSpy } = setupFrom({ data: { id: 'key-1', unattended_commit_limit: null } })
|
||||
const res = await PATCH(patch({ unattended_commit_limit: null }), params)
|
||||
expect(res.status).toBe(200)
|
||||
expect(updateSpy).toHaveBeenCalledWith({ unattended_commit_limit: null })
|
||||
})
|
||||
|
||||
it('rejects zero, negatives and non-numbers with 400', async () => {
|
||||
setupFrom({ data: { id: 'key-1' } })
|
||||
for (const bad of [0, -1, '5000', {}]) {
|
||||
const res = await PATCH(patch({ unattended_commit_limit: bad }), params)
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('requires the field rather than treating an empty body as "clear it"', async () => {
|
||||
setupFrom({ data: { id: 'key-1' } })
|
||||
const res = await PATCH(patch({}), params)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the key belongs to another company or is revoked', async () => {
|
||||
setupFrom({ data: null })
|
||||
const res = await PATCH(patch({ unattended_commit_limit: 5000 }), params)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 500 when the update fails', async () => {
|
||||
setupFrom({ error: { message: 'boom' } })
|
||||
const res = await PATCH(patch({ unattended_commit_limit: 5000 }), params)
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,21 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Approval authority in SEK: the largest amount this key may commit with no
|
||||
* human in the loop. null clears the ceiling (unlimited, the default).
|
||||
*
|
||||
* Bounded at 1 000 000 000 so a typo cannot store a number the numeric(14,2)
|
||||
* column would reject at insert time with a raw Postgres error. The DB CHECK
|
||||
* (> 0) is the real guarantee; this is the friendly message in front of it.
|
||||
*/
|
||||
const patchSchema = z.object({
|
||||
unattended_commit_limit: z.number().positive().max(1_000_000_000).nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete)
|
||||
*/
|
||||
@@ -26,3 +40,43 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* PATCH /api/settings/api-keys/[id]: set the key's unattended commit limit.
|
||||
*
|
||||
* Deliberately narrow: name and scopes are NOT editable here. Silently
|
||||
* widening a key's scopes after the fact would defeat the point of showing the
|
||||
* scope list at creation, and the separation-of-duties check
|
||||
* (findStageApproveConflict) runs only on POST.
|
||||
*/
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'api_key.update',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const validation = await validateBody(request, patchSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
// Revoked keys are deliberately excluded: raising a limit on a key that no
|
||||
// longer authenticates reads as re-enabling it, and it does not.
|
||||
const { data, error } = await supabase
|
||||
.from('api_keys')
|
||||
.update({ unattended_commit_limit: validation.data.unattended_commit_limit })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.is('revoked_at', null)
|
||||
.select('id, unattended_commit_limit')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'API-nyckeln hittades inte.' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export const GET = withRouteContext(
|
||||
// active company too: they're simulation-only, so they never write real data.)
|
||||
const { data, error } = await supabase
|
||||
.from('api_keys')
|
||||
.select('id, key_prefix, name, scopes, mode, rate_limit_rpm, last_used_at, revoked_at, created_at')
|
||||
.select('id, key_prefix, name, scopes, mode, rate_limit_rpm, unattended_commit_limit, last_used_at, revoked_at, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The approval-authority ceiling on POST /v1/.../journal-entries/:id/commit.
|
||||
*
|
||||
* This is the REST half of the envelope. The MCP half lives in
|
||||
* lib/pending-operations/__tests__/commit-unattended-limit.test.ts. Both must
|
||||
* hold, because an API key can reach the ledger through either, and a ceiling
|
||||
* enforced on only one of them is not a ceiling.
|
||||
*
|
||||
* The property under test is not just "it returns 403": it is that the DRAFT
|
||||
* SURVIVES. commitEntry must never be called, so the voucher sequence does not
|
||||
* advance (BFL 5 kap. 7 §) and a human can commit the same draft from the app.
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
commitEntry: vi.fn().mockResolvedValue({ id: 'entry-1', status: 'posted' }),
|
||||
getNextVoucherNumber: vi.fn().mockResolvedValue(143),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { commitEntry } from '@/lib/bookkeeping/engine'
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockCommitEntry = commitEntry as ReturnType<typeof vi.fn>
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const ENTRY_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
const DRAFT = {
|
||||
id: ENTRY_ID,
|
||||
status: 'draft',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
entry_date: '2026-08-31',
|
||||
}
|
||||
|
||||
/**
|
||||
* Two tables answer here: journal_entries (the pre-flight maybeSingle) and
|
||||
* journal_entry_lines (the paginated debit sum). The lines queue returns the
|
||||
* rows once and then an empty page, which is what ends fetchAllRows' loop.
|
||||
*/
|
||||
function makeSupabase(lineDebits: number[]) {
|
||||
const linePages = [
|
||||
{ data: lineDebits.map((debit_amount, i) => ({ id: `line-${i}`, debit_amount })), error: null },
|
||||
{ data: [], error: null },
|
||||
]
|
||||
const build = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(linePages.length > 1 ? linePages.shift()! : linePages[0]!)
|
||||
}
|
||||
if (prop === 'maybeSingle' || prop === 'single') {
|
||||
// Three tables answer a single-row read here, and each needs a
|
||||
// different answer: company_members proves the key may touch this
|
||||
// company (null would be a 404 before the handler ever runs),
|
||||
// journal_entries is the draft, and the idempotency store must be
|
||||
// null or the request reads as a replay and short-circuits with 409.
|
||||
const rows: Record<string, unknown> = {
|
||||
company_members: { company_id: COMPANY_ID, user_id: 'user-1', role: 'owner' },
|
||||
journal_entries: DRAFT,
|
||||
}
|
||||
return () => Promise.resolve({ data: rows[table] ?? null, error: null })
|
||||
}
|
||||
return () => build(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => build(table)) }
|
||||
}
|
||||
|
||||
function makeRequest(dryRun = false): Request {
|
||||
return new Request(
|
||||
`http://localhost/api/v1/companies/${COMPANY_ID}/journal-entries/${ENTRY_ID}/commit${dryRun ? '?dry_run=true' : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': `idem${Math.floor(Math.random() * 1e6)}-1010-4abc-8def-1234567890ab`,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const routeParams = { params: Promise.resolve({ companyId: COMPANY_ID, id: ENTRY_ID }) }
|
||||
|
||||
function auth(unattendedCommitLimit: number | null) {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['bookkeeping:write'],
|
||||
mode: 'live',
|
||||
unattendedCommitLimit,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCommitEntry.mockResolvedValue({ id: ENTRY_ID, status: 'posted' })
|
||||
})
|
||||
|
||||
describe('journal-entries.commit: unattended commit limit', () => {
|
||||
it('refuses a 50 000 kr draft on a key capped at 10 000, without committing it', async () => {
|
||||
auth(10000)
|
||||
mockServiceClient.mockReturnValue(makeSupabase([30000, 20000]))
|
||||
|
||||
const res = await POST(makeRequest(), routeParams)
|
||||
const body = (await res.json()) as { error: { code: string; details: Record<string, unknown> } }
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(body.error.code).toBe('UNATTENDED_COMMIT_LIMIT_EXCEEDED')
|
||||
expect(body.error.details).toMatchObject({ attempted: 50000, limit: 10000 })
|
||||
// The whole point: the draft is untouched and the sequence never moved.
|
||||
expect(mockCommitEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses the dry run too, rather than promising a voucher number it cannot deliver', async () => {
|
||||
auth(10000)
|
||||
mockServiceClient.mockReturnValue(makeSupabase([50000]))
|
||||
|
||||
const res = await POST(makeRequest(true), routeParams)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('commits when the total is at or under the ceiling', async () => {
|
||||
auth(10000)
|
||||
mockServiceClient.mockReturnValue(makeSupabase([7500, 2500]))
|
||||
|
||||
const res = await POST(makeRequest(), routeParams)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCommitEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits any amount when the key has no ceiling', async () => {
|
||||
auth(null)
|
||||
mockServiceClient.mockReturnValue(makeSupabase([9_000_000]))
|
||||
|
||||
const res = await POST(makeRequest(), routeParams)
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockCommitEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not even read the lines when there is no ceiling', async () => {
|
||||
auth(null)
|
||||
const supabase = makeSupabase([1])
|
||||
mockServiceClient.mockReturnValue(supabase)
|
||||
|
||||
await POST(makeRequest(), routeParams)
|
||||
const tables = supabase.from.mock.calls.map((c) => c[0])
|
||||
expect(tables).not.toContain('journal_entry_lines')
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { commitEntry, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const JE_RESPONSE_COLUMNS =
|
||||
@@ -47,6 +49,7 @@ registerEndpoint({
|
||||
'Idempotency-Key is mandatory.',
|
||||
'Posted entries cannot be edited. Plan the lines carefully or call /correct after commit if you need to change them.',
|
||||
'Voucher numbers are sequential within (fiscal_period_id, voucher_series). A commit failure (e.g. period locked between draft creation and commit) does not advance the sequence.',
|
||||
'If the key has an unattended commit limit, an entry above it returns 403 UNATTENDED_COMMIT_LIMIT_EXCEEDED and stays a draft for a human to commit. Do not split it into smaller entries: one affärshändelse is one verifikat (BFL 5 kap. 6 §).',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
@@ -95,6 +98,59 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// ── Approval-authority ceiling.
|
||||
//
|
||||
// The second of the two places an API key posts money (the first is
|
||||
// commitPendingOperation, for MCP). Placed after the draft-exists and
|
||||
// status checks and BEFORE commitEntry, so a refusal leaves the draft a
|
||||
// draft: nothing is destroyed, and the voucher sequence does not move
|
||||
// (BFL 5 kap. 7 §). The human commits the same draft from the app.
|
||||
//
|
||||
// Runs before the dry-run branch on purpose. A dry run that reports
|
||||
// "would post voucher 143" for a commit the key is not allowed to make
|
||||
// is a lie the agent will act on.
|
||||
//
|
||||
// Known and accepted: the line sum is read here and the entry is
|
||||
// committed below, so a concurrent write to the draft's lines between
|
||||
// the two can post more than the ceiling. Closing that window means
|
||||
// enforcing inside commit_journal_entry, where a RAISE is swallowed
|
||||
// into a retryable 500 and, on the MCP path, destroys the staged
|
||||
// operation. The trade is deliberate and consistent with what this
|
||||
// control claims to be: a blast-radius cap on a looping or
|
||||
// prompt-injected agent, NOT a security boundary. A per-entry ceiling
|
||||
// is already defeated by splitting one entry into several, which needs
|
||||
// no race at all. The primitive that actually bounds exposure is a
|
||||
// cumulative rolling-window limit, tracked separately.
|
||||
if (ctx.unattendedCommitLimit !== null) {
|
||||
const lines = await fetchAllRows<{ id: string; debit_amount: number | null }>(
|
||||
({ from, to }) =>
|
||||
ctx.supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, debit_amount')
|
||||
.eq('journal_entry_id', entryId)
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
// The sum is money, so pagination correctness is not optional: a
|
||||
// regressed order would double-count a line and refuse a legitimate
|
||||
// commit. fetch-all.ts documents dedupeBy for exactly this case.
|
||||
{ dedupeBy: (r) => r.id },
|
||||
)
|
||||
// Debits equal credits on any entry that can be committed (the balance
|
||||
// trigger enforces it), so the debit side alone is the entry's amount.
|
||||
const attempted = roundOre(lines.reduce((sum, l) => sum + (l.debit_amount ?? 0), 0))
|
||||
if (attempted > ctx.unattendedCommitLimit) {
|
||||
return v1ErrorResponseFromCode('UNATTENDED_COMMIT_LIMIT_EXCEEDED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
attempted,
|
||||
limit: ctx.unattendedCommitLimit,
|
||||
journal_entry_id: entryId,
|
||||
entry_status: 'draft',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
// Report the next voucher number WITHOUT advancing the sequence. The
|
||||
// engine helper `getNextVoucherNumber` is a peek + increment; for a
|
||||
|
||||
Reference in New Issue
Block a user