fix(entitlements): gate paid AI document OCR server-side (free-tier leak) (#852)

* fix(entitlements): gate paid AI document OCR server-side (free-tier leak)

Free/manual-tier companies could trigger paid Bedrock OCR (extractInvoiceFields)
with no `ai` capability check, on every transport:
- invoice-inbox HTTP paths — /upload + email /inbound (shared uploadAndExtract),
  /items/:id/attach-document, /items/:id/retry-extraction (4 call sites, zero
  capability refs);
- the gnubok_upload_document MCP tool — absent from MCP_TOOL_CAPABILITY_MAP, so a
  free-tier API key (incl. the claude.ai connector's minted gnubok_sk_ key) got
  unlimited AI extraction. This disproved the keys.ts "no MCP tool invokes AI"
  comment.

Fix (money-blocker for the free/paid tier cutover):
- Gate the 3 inbox call sites on hasCapability(CAPABILITY.ai). Upload + attach
  degrade gracefully (document still stored; extraction skipped with reason
  `no_ai_entitlement`, highest priority in the existing skipReason chain). Retry
  is an explicit "run AI now" action, so it hard-blocks with 403
  capabilityBlockedResponse.
- Register gnubok_upload_document -> CAPABILITY.ai in MCP_TOOL_CAPABILITY_MAP; the
  dispatcher already enforces the map. Correct the stale keys.ts comment and the
  misleading "deterministic field extraction" tool description + manifest copy
  (the extension migrated regex -> AI OCR).

Tests: no-AI upload/attach skip + retry 403 (sandbox-skip-extraction), retry 403
(retry-extraction), and the MCP map contract + refined dispatch<->commit parity
(capability-maps: upload_document is dispatch-only, no commit counterpart).

Self-hosted stays all-on (hasCapability short-circuits). No migration.
Follow-up (not in scope): capability-blind DashboardNav (free/paid rails identical)
and /chat gated on isVerified not `ai` — see dev_docs/nav_ia_redesign.md Part 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): assert gnubok_upload_document is ai-gated at dispatch

The gnubok_upload_document handler runs extractInvoiceFields (Bedrock OCR)
inline rather than through the entitlement-gated uploadAndExtract, so the
central MCP_TOOL_CAPABILITY_MAP dispatch check is the only paywall on that
transport. Lock it with a test (flagged by PR review as an untested money
path) so a free-tier connector key can never reach paid OCR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 13:36:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent f675ae6565
commit 6be6510d73
10 changed files with 223 additions and 27 deletions
@@ -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<typeof import('@/lib/entitlements/has-capability')>()
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({
@@ -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<typeof import('@/lib/entitlements/has-capability')>()
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<string, unknown> } = {}
// 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<string, unknown> }>(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<string, unknown> }>(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()
})
})
@@ -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<typeof import('@/lib/entitlements/has-capability')>()
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) {
+38 -14
View File
@@ -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.' },
@@ -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."
}
}
@@ -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<string, unknown>).capability_blocked).toBe(true)
expect((payload.error as Record<string, unknown>).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)
+1 -1
View File
@@ -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,
@@ -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<string>(['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)
})
})
+8 -3
View File
@@ -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<Partial<Record<string, CapabilityKey>>> = {
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
/**
@@ -98,7 +98,7 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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"