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:
co-authored by
Claude Opus 4.8
parent
f675ae6565
commit
6be6510d73
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user