diff --git a/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts b/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts index b7d38b87..b344b474 100644 --- a/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts +++ b/extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts @@ -15,8 +15,17 @@ vi.mock('@/lib/rate-limits/inbox', () => ({ checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }), })) +// Paid AI OCR gate. Retry is an explicit "run AI now" action, so a company +// without CAPABILITY.ai is hard-blocked (403). Default to entitled here; +// capabilityBlockedResponse stays real so the 403 envelope is exercised. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } +}) + import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' +import { hasCapability } from '@/lib/entitlements/has-capability' function findRoute(method: string, path: string) { return invoiceInboxExtension.apiRoutes!.find( @@ -62,6 +71,7 @@ const EXTRACTION_SUCCESS = { beforeEach(() => { vi.clearAllMocks() vi.mocked(checkInboxUploadRateLimit).mockResolvedValue({ ok: true }) + vi.mocked(hasCapability).mockResolvedValue(true) }) describe('POST /items/:id/retry-extraction', () => { @@ -105,6 +115,21 @@ describe('POST /items/:id/retry-extraction', () => { expect(body.error).toMatch(/redan bokfört/i) }) + it('hard-blocks with 403 capability_blocked when the company lacks the ai capability', async () => { + vi.mocked(hasCapability).mockResolvedValueOnce(false) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null }, + error: null, + }) // item lookup — the ai gate fires immediately after + const res = await retryRoute.handler(makeReq(), buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ capability_blocked: boolean; capability: string }>(res) + expect(status).toBe(403) + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe('ai') + expect(extractInvoiceFields).not.toHaveBeenCalled() + }) + it('returns 400 when the item has no attached document', async () => { const { supabase, enqueue } = createQueuedMockSupabase() enqueue({ diff --git a/extensions/general/invoice-inbox/__tests__/sandbox-skip-extraction.test.ts b/extensions/general/invoice-inbox/__tests__/sandbox-skip-extraction.test.ts index a5b6b8ea..2a3ecdb8 100644 --- a/extensions/general/invoice-inbox/__tests__/sandbox-skip-extraction.test.ts +++ b/extensions/general/invoice-inbox/__tests__/sandbox-skip-extraction.test.ts @@ -32,7 +32,17 @@ vi.mock('@/lib/processing-history/append', () => ({ appendProcessingHistory: vi.fn().mockResolvedValue(undefined), })) +// Paid AI OCR gate: hasCapability('ai') decides whether Bedrock runs. Default +// to entitled (true) so the sandbox/page-count reasons are exercised; the +// no-AI tests below override to false. capabilityBlockedResponse stays real so +// the retry 403 envelope is genuine. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } +}) + import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' +import { hasCapability } from '@/lib/entitlements/has-capability' function findRoute(method: string, path: string) { return invoiceInboxExtension.apiRoutes!.find( @@ -117,6 +127,7 @@ function makeUploadSupabase(opts: { beforeEach(() => { vi.clearAllMocks() + vi.mocked(hasCapability).mockResolvedValue(true) }) describe('Sandbox companies skip Bedrock extraction', () => { @@ -236,3 +247,90 @@ describe('Sandbox companies skip Bedrock extraction', () => { }) }) }) + +// The paid-tier paywall: a free/manual-tier company (no `ai` capability) must +// never trigger Bedrock OCR. Upload + attach degrade gracefully (document is +// stored, extraction skipped with reason 'no_ai_entitlement'); retry is an +// explicit "run AI" action and hard-blocks with 403 capability_blocked. +describe('Free tier (no ai capability) does not run Bedrock extraction', () => { + it('POST /upload — skips extraction with skip_reason=no_ai_entitlement (highest priority)', async () => { + vi.mocked(hasCapability).mockResolvedValueOnce(false) + const captured: { row?: Record } = {} + // Not a sandbox: proves the ai gate wins over a passing sandbox check. + const supabase = makeUploadSupabase({ isSandbox: false, captured }) + + const bytes = await makePdfBuffer(1) + const file = new File([bytes as BlobPart], 'receipt.pdf', { type: 'application/pdf' }) + const form = new FormData() + form.set('file', file) + + const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(extractInvoiceFields).not.toHaveBeenCalled() + expect(body.data.extraction_skipped).toBe(true) + expect(body.data.skip_reason).toBe('no_ai_entitlement') + expect(captured.row?.extraction_skipped).toBe(true) + }) + + it('POST /items/:id/attach-document — skips extraction when the company lacks ai', async () => { + vi.mocked(hasCapability).mockResolvedValueOnce(false) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'item-1', + document_id: null, + status: 'received', + correlation_id: null, + created_supplier_invoice_id: null, + }, + error: null, + }) + // sandbox check → false (so the ai gate, not sandbox, is what skips) + enqueue({ data: { is_sandbox: false }, error: null }) + // update row + enqueue({ data: null, error: null }) + + const bytes = await makePdfBuffer(1) + const file = new File([bytes as BlobPart], 'attach.pdf', { type: 'application/pdf' }) + const form = new FormData() + form.set('file', file) + + const req = new Request('http://localhost:3000/items/item-1/attach-document?_id=item-1', { + method: 'POST', + body: form, + }) + + const res = await attachRoute.handler(req, buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ data: Record }>(res) + + expect(status).toBe(200) + expect(extractInvoiceFields).not.toHaveBeenCalled() + expect(body.data.extraction_skipped).toBe(true) + expect(body.data.skip_reason).toBe('no_ai_entitlement') + }) + + it('POST /items/:id/retry-extraction — hard-blocks with 403 capability_blocked', async () => { + vi.mocked(hasCapability).mockResolvedValueOnce(false) + const { supabase, enqueue } = createQueuedMockSupabase() + // item lookup — the ai gate fires immediately after, before the sandbox check + enqueue({ + data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null }, + error: null, + }) + + const req = createMockRequest('/items/item-1/retry-extraction', { + method: 'POST', + searchParams: { _id: 'item-1' }, + }) + + const res = await retryRoute.handler(req, buildCtx(supabase)) + const { status, body } = await parseJsonResponse<{ capability_blocked: boolean; capability: string }>(res) + + expect(status).toBe(403) + expect(body.capability_blocked).toBe(true) + expect(body.capability).toBe('ai') + expect(extractInvoiceFields).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/invoice-inbox/__tests__/upload-page-count-gate.test.ts b/extensions/general/invoice-inbox/__tests__/upload-page-count-gate.test.ts index 36cd55f7..321cab39 100644 --- a/extensions/general/invoice-inbox/__tests__/upload-page-count-gate.test.ts +++ b/extensions/general/invoice-inbox/__tests__/upload-page-count-gate.test.ts @@ -29,6 +29,14 @@ vi.mock('@/lib/processing-history/append', () => ({ appendProcessingHistory: vi.fn().mockResolvedValue(undefined), })) +// Paid AI OCR gate: hasCapability('ai') decides whether Bedrock runs. Default +// to entitled (true) so these page-count tests exercise the page-count reason, +// not the no-AI one; the no-AI path is covered in sandbox-skip-extraction.test.ts. +vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) } +}) + import { extractInvoiceFields, emptyResult } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' function findRoute(method: string, path: string) { diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 3680f987..bd8a48a7 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -26,6 +26,8 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas' import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core' +import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' import { appendProcessingHistory } from '@/lib/processing-history/append' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' import { simpleParser } from 'mailparser' @@ -217,15 +219,23 @@ async function uploadAndExtract( const gatedByPageCount = pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT const sandbox = await isSandboxCompany(supabase, companyId) - // Skip-reason priority: sandbox > page-count > client opt-out. Sandbox - // wins because it's a hard cost-control rule, not a heuristic. - const skipReason: 'too_many_pages' | 'client_opt_out' | 'sandbox' | null = sandbox - ? 'sandbox' - : gatedByPageCount - ? 'too_many_pages' - : opts.skipExtraction - ? 'client_opt_out' - : null + // Paid-tier gate: AI document OCR (Bedrock, via extractInvoiceFields) is the + // `ai` capability. A company without it (free/manual tier) must never trigger + // paid extraction — we seed an empty skeleton exactly like the sandbox / BYO- + // extraction path, so the document is still stored and can be filled in + // manually. Highest priority (a hard paywall rule, not a heuristic). + const hasAiEntitlement = await hasCapability(supabase, companyId, CAPABILITY.ai) + // Skip-reason priority: no-AI-entitlement > sandbox > page-count > client opt-out. + const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'client_opt_out' | 'sandbox' | null = + !hasAiEntitlement + ? 'no_ai_entitlement' + : sandbox + ? 'sandbox' + : gatedByPageCount + ? 'too_many_pages' + : opts.skipExtraction + ? 'client_opt_out' + : null const skipExtraction = skipReason !== null // Bring-your-own-extraction: skip the Bedrock call entirely and seed an @@ -804,11 +814,18 @@ export const invoiceInboxExtension: Extension = { const gatedByPageCount = pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT const sandbox = await isSandboxCompany(ctx.supabase, ctx.companyId) - const skipReason: 'too_many_pages' | 'sandbox' | null = sandbox - ? 'sandbox' - : gatedByPageCount - ? 'too_many_pages' - : null + // Paid-tier gate: no `ai` capability → no Bedrock OCR (seed empty + // skeleton; the attached document is still stored). Same paywall as + // the shared upload path above. + const hasAiEntitlement = await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai) + const skipReason: 'no_ai_entitlement' | 'too_many_pages' | 'sandbox' | null = + !hasAiEntitlement + ? 'no_ai_entitlement' + : sandbox + ? 'sandbox' + : gatedByPageCount + ? 'too_many_pages' + : null const skipExtraction = skipReason !== null const { data: extracted } = skipExtraction @@ -1107,6 +1124,13 @@ export const invoiceInboxExtension: Extension = { ) } + // Paid-tier gate: retry is an explicit "run AI OCR now" action, so a + // company without the `ai` capability is hard-blocked (403) rather than + // silently emptied — there is nothing to retry without the entitlement. + if (!(await hasCapability(ctx.supabase, ctx.companyId, CAPABILITY.ai))) { + return capabilityBlockedResponse(CAPABILITY.ai) + } + if (await isSandboxCompany(ctx.supabase, ctx.companyId)) { return NextResponse.json( { error: 'AI-tolkning är inte tillgänglig i sandlådan.' }, diff --git a/extensions/general/invoice-inbox/manifest.json b/extensions/general/invoice-inbox/manifest.json index b75b1334..72fda503 100644 --- a/extensions/general/invoice-inbox/manifest.json +++ b/extensions/general/invoice-inbox/manifest.json @@ -17,6 +17,6 @@ "hasOwnData": true, "readsCoreTables": ["document_attachments", "suppliers"], "description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält", - "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning." + "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt." } } diff --git a/extensions/general/mcp-server/__tests__/capability-gate.test.ts b/extensions/general/mcp-server/__tests__/capability-gate.test.ts index 41262bd4..7fecb99d 100644 --- a/extensions/general/mcp-server/__tests__/capability-gate.test.ts +++ b/extensions/general/mcp-server/__tests__/capability-gate.test.ts @@ -40,9 +40,11 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => { validateApiKey: vi.fn().mockResolvedValue({ userId: 'user-1', companyId: '11111111-1111-4111-8111-111111111111', - // Holds the SCOPES for all three paid tools so the scope gate passes and - // the CAPABILITY gate is what we exercise. - scopes: ['invoices:write', 'skatteverket:write', 'reports:read'], + // Holds the SCOPES for every paid tool under test (send_invoice → + // invoices:write, agi_submit → skatteverket:write, upload_document → + // transactions:write) so the scope gate passes and the CAPABILITY gate is + // what we exercise. + scopes: ['invoices:write', 'skatteverket:write', 'reports:read', 'transactions:write'], apiKeyId: 'key-1', apiKeyName: 'Test Key', }), @@ -129,6 +131,24 @@ describe('MCP capability gate', () => { expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'skatteverket') }) + it('blocks gnubok_upload_document when ai is not entitled — the paid Bedrock OCR path', async () => { + // gnubok_upload_document runs extractInvoiceFields (Bedrock OCR) inline in + // its handler, NOT through the entitlement-gated uploadAndExtract. The + // central dispatch map is therefore the ONLY paywall on this transport — + // this test locks it so a free-tier connector key can never reach OCR. + mockHasCapability.mockResolvedValue(false) + + const response = await handleMcpRequest( + mcpToolCall('gnubok_upload_document', { file_name: 'faktura.pdf', file_content_base64: 'JVBERi0=' }), + ) + const { isError, payload } = await parsedToolResult(response) + + expect(isError).toBe(true) + expect((payload.error as Record).capability_blocked).toBe(true) + expect((payload.error as Record).capability).toBe('ai') + expect(mockHasCapability).toHaveBeenCalledWith(expect.anything(), '11111111-1111-4111-8111-111111111111', 'ai') + }) + it('lets a free tool through without consulting the capability gate', async () => { mockHasCapability.mockResolvedValue(false) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 1e322eee..e5b6e44e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -6141,7 +6141,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_upload_document', title: 'Upload Document to Inbox', - description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs deterministic field extraction on text-based PDFs.', + description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs AI field extraction (Bedrock OCR) — requires the AI capability.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/entitlements/__tests__/capability-maps.test.ts b/lib/entitlements/__tests__/capability-maps.test.ts index fcbcda5b..f80509c3 100644 --- a/lib/entitlements/__tests__/capability-maps.test.ts +++ b/lib/entitlements/__tests__/capability-maps.test.ts @@ -12,12 +12,22 @@ import { * external-service tool silently bypassing the paywall — mirrors the * TOOL_SCOPE_MAP assertions in the mcp-server tests. */ +/** + * MCP tools that invoke a paid capability directly (no stage→commit round-trip), + * so they are gated at DISPATCH only and have no commit-time (operation-map) + * counterpart. gnubok_upload_document runs Bedrock OCR inline via + * extractInvoiceFields — it never stages a pending_operation. + */ +const DISPATCH_ONLY_MCP_TOOLS = new Set(['gnubok_upload_document']) + describe('MCP_TOOL_CAPABILITY_MAP', () => { - it('gates exactly the three paid external-service MCP tools', () => { + it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tool)', () => { expect(MCP_TOOL_CAPABILITY_MAP).toEqual({ gnubok_send_invoice: CAPABILITY.email_send, gnubok_vat_declaration_submit: CAPABILITY.skatteverket, gnubok_agi_submit: CAPABILITY.skatteverket, + // Dispatch-only AI tool — inline Bedrock OCR, no staged operation. + gnubok_upload_document: CAPABILITY.ai, }) }) @@ -43,9 +53,15 @@ describe('PAID_OPERATION_CAPABILITY_MAP', () => { } }) - it('covers the same set of capabilities as the MCP tool map (dispatch ↔ commit parity)', () => { - expect(new Set(Object.values(PAID_OPERATION_CAPABILITY_MAP))).toEqual( - new Set(Object.values(MCP_TOOL_CAPABILITY_MAP)), + it('covers the same capabilities as the STAGING MCP tools (dispatch ↔ commit parity)', () => { + // Parity applies to staging tools only: an op that can be staged via MCP OR + // approved in the UI must be gated on both transports. Dispatch-only tools + // (inline AI OCR) have no commit counterpart and are excluded. + const stagingMcpCaps = new Set( + Object.entries(MCP_TOOL_CAPABILITY_MAP) + .filter(([tool]) => !DISPATCH_ONLY_MCP_TOOLS.has(tool)) + .map(([, cap]) => cap), ) + expect(new Set(Object.values(PAID_OPERATION_CAPABILITY_MAP))).toEqual(stagingMcpCaps) }) }) diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index c5493b12..3e8a93f0 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -57,18 +57,23 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ /** * Paid MCP tools → required capability. The MCP/agent path is a paid chokepoint * just like the HTTP routes, so the dispatcher gates these the same way it gates - * API-key scope (see mcp-server `tools/call`). Only external-service WRITE tools + * API-key scope (see mcp-server `tools/call`). External-service WRITE tools * appear here: send_invoice (email) and the two Skatteverket submissions. The * read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status) * stay free — the §4 carve-out forbids blocking a statutory filing obligation. * - * No MCP tool invokes AI or triggers bank sync, so those PAID capabilities have - * no entry here — they are reachable only via already-gated HTTP routes/handlers. + * gnubok_upload_document invokes AI (Bedrock document OCR via + * extractInvoiceFields), so it is gated on CAPABILITY.ai — the same paywall the + * HTTP inbox upload/attach/retry paths enforce. Without this entry a free-tier + * API key (incl. the claude.ai connector's minted gnubok_sk_ key) could trigger + * paid AI extraction. bank_sync has no MCP tool (bank sync is cron/HTTP only). */ export const MCP_TOOL_CAPABILITY_MAP: Readonly>> = { gnubok_send_invoice: CAPABILITY.email_send, gnubok_vat_declaration_submit: CAPABILITY.skatteverket, gnubok_agi_submit: CAPABILITY.skatteverket, + // AI document OCR (Bedrock) — the inbox's paid extraction, reachable via MCP. + gnubok_upload_document: CAPABILITY.ai, } as const /** diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index ab32b519..121eef45 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -98,7 +98,7 @@ export const EXTENSION_DEFINITIONS: Record = { "icon": "Inbox", "dataPattern": "both", "description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält", - "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning.", + "longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum läses av med AI (kräver AI-funktionen). Utan AI lagras dokumentet ändå och fälten fylls i manuellt.", "readsCoreTables": [ "document_attachments", "suppliers"