diff --git a/extensions/general/mcp-server/__tests__/capability-gate.test.ts b/extensions/general/mcp-server/__tests__/capability-gate.test.ts index 917a3a1a..bafcfd17 100644 --- a/extensions/general/mcp-server/__tests__/capability-gate.test.ts +++ b/extensions/general/mcp-server/__tests__/capability-gate.test.ts @@ -59,7 +59,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => { userId: 'user-1', companyId: '11111111-1111-4111-8111-111111111111', // Holds the SCOPES for every paid tool under test (send_invoice → - // invoices:write, agi_submit → skatteverket:write, upload_document → + // invoices:write, agi_submit → skatteverket:write, document uploads → // transactions:write) so the scope gate passes and the CAPABILITY gate is // what we exercise. scopes: ['invoices:write', 'skatteverket:write', 'reports:read', 'transactions:write'], @@ -152,16 +152,23 @@ 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. + it.each([ + ['gnubok_create_document_upload', { file_name: 'faktura.pdf' }], + [ + 'gnubok_complete_document_upload', + { + upload_id: '33333333-3333-4333-8333-333333333333', + file_name: 'faktura.pdf', + mime_type: 'application/pdf', + }, + ], + ['gnubok_upload_document', { file_name: 'faktura.pdf', file_content_base64: 'JVBERi0=' }], + ])('blocks %s when ai is not entitled', async (toolName, args) => { + // The central dispatch map is the paywall for every MCP document-upload + // step, so a free-tier connector key can never reach the paid OCR flow. mockHasCapability.mockResolvedValue(false) - const response = await handleMcpRequest( - mcpToolCall('gnubok_upload_document', { file_name: 'faktura.pdf', file_content_base64: 'JVBERi0=' }), - ) + const response = await handleMcpRequest(mcpToolCall(toolName, args)) const { isError, payload } = await parsedToolResult(response) expect(isError).toBe(true) diff --git a/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts b/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts new file mode 100644 index 00000000..a07ae107 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/document-upload-tools.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeDocumentAttachment } from '@/tests/helpers' +import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys' +import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys' + +const mocks = vi.hoisted(() => ({ + createPendingDocumentUpload: vi.fn(), + completePendingDocumentUpload: vi.fn(), + extractInvoiceFields: vi.fn(), +})) + +vi.mock('@/lib/core/documents/document-service', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPendingDocumentUpload: mocks.createPendingDocumentUpload, + completePendingDocumentUpload: mocks.completePendingDocumentUpload, + } +}) + +vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@/extensions/general/invoice-inbox/lib/extract-invoice-fields') + >() + return { ...actual, extractInvoiceFields: mocks.extractInvoiceFields } +}) + +import { tools } from '../server' + +const companyId = '11111111-1111-4111-8111-111111111111' +const userId = '22222222-2222-4222-8222-222222222222' +const uploadId = '33333333-3333-4333-8333-333333333333' + +function findTool(name: string) { + const tool = tools.find((candidate) => candidate.name === name) + if (!tool) throw new Error(`Tool not found: ${name}`) + return tool +} + +function makeQueryBuilder(result: { data: unknown; error: unknown }) { + const builder: Record = {} + for (const method of ['select', 'eq', 'limit', 'insert']) { + builder[method] = vi.fn().mockReturnValue(builder) + } + builder.maybeSingle = vi.fn().mockResolvedValue(result) + builder.single = vi.fn().mockResolvedValue(result) + return builder +} + +describe('MCP model-free document upload tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createPendingDocumentUpload.mockResolvedValue({ + uploadId, + signedUrl: 'https://storage.example/upload?token=signed', + expiresAt: '2026-08-03T12:00:00.000Z', + }) + mocks.completePendingDocumentUpload.mockResolvedValue({ + document: makeDocumentAttachment({ + id: uploadId, + user_id: userId, + company_id: companyId, + file_name: 'invoice.pdf', + mime_type: 'application/pdf', + }), + buffer: new TextEncoder().encode('%PDF-1.4\n%%EOF\n').buffer, + }) + mocks.extractInvoiceFields.mockResolvedValue({ + data: { + supplier: { name: 'Synthetic Supplier AB', orgNumber: null }, + invoice: { number: 'INV-1' }, + }, + }) + }) + + it('returns an unauthenticated PUT URL without accepting file bytes', async () => { + const tool = findTool('gnubok_create_document_upload') + const result = await tool.execute( + { file_name: 'invoice.pdf' }, + companyId, + userId, + {} as never, + ) + + expect(mocks.createPendingDocumentUpload).toHaveBeenCalledWith( + expect.anything(), + companyId, + userId, + expect.stringMatching(/^[0-9a-f-]{36}$/), + 'invoice.pdf', + ) + expect(result).toEqual({ + upload_id: uploadId, + upload_url: 'https://storage.example/upload?token=signed', + expires_at: '2026-08-03T12:00:00.000Z', + }) + const schema = tool.inputSchema as { properties: Record } + expect(schema.properties).not.toHaveProperty('file_content_base64') + }) + + it('completes the reserved upload and uses the upload UUID for both records', async () => { + const inboxInsert = makeQueryBuilder({ data: { id: uploadId, status: 'received' }, error: null }) + const invoiceLookups = [ + makeQueryBuilder({ data: null, error: null }), + makeQueryBuilder({ data: null, error: null }), + inboxInsert, + ] + const supplier = makeQueryBuilder({ data: null, error: null }) + const from = vi.fn((table: string) => { + if (table === 'invoice_inbox_items') return invoiceLookups.shift() + if (table === 'suppliers') return supplier + throw new Error(`Unexpected table: ${table}`) + }) + + const result = await findTool('gnubok_complete_document_upload').execute( + { upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' }, + companyId, + userId, + { from } as never, + ) + + expect(mocks.completePendingDocumentUpload).toHaveBeenCalledWith( + expect.anything(), + companyId, + userId, + uploadId, + 'invoice.pdf', + 'application/pdf', + ) + expect(inboxInsert.insert).toHaveBeenCalledWith( + expect.objectContaining({ id: uploadId, document_id: uploadId }), + ) + expect(result).toMatchObject({ + document_id: uploadId, + inbox_item_id: uploadId, + status: 'received', + }) + expect(mocks.extractInvoiceFields).toHaveBeenCalledOnce() + }) + + it('returns an already completed inbox item without downloading or extracting again', async () => { + const existing = makeQueryBuilder({ + data: { + id: uploadId, + document_id: uploadId, + status: 'received', + extracted_data: { invoice: { number: 'INV-1' } }, + matched_supplier_id: null, + }, + error: null, + }) + const result = await findTool('gnubok_complete_document_upload').execute( + { upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' }, + companyId, + userId, + { from: vi.fn().mockReturnValue(existing) } as never, + ) + + expect(result).toMatchObject({ document_id: uploadId, inbox_item_id: uploadId }) + expect(mocks.completePendingDocumentUpload).not.toHaveBeenCalled() + expect(mocks.extractInvoiceFields).not.toHaveBeenCalled() + }) + + it('keeps scope and AI capability gates aligned across all upload paths', () => { + for (const name of [ + 'gnubok_create_document_upload', + 'gnubok_complete_document_upload', + 'gnubok_upload_document', + ]) { + expect(TOOL_SCOPE_MAP[name]).toBe('transactions:write') + expect(MCP_TOOL_CAPABILITY_MAP[name]).toBe('ai') + } + }) +}) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index d0673b05..1a19464c 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -152,9 +152,19 @@ describe('tools/list payload size guard', () => { // hint prose was trimmed to the floor first (~30 tokens recovered); // headroom before the change was ~14 tokens, so even the trimmed wire // contract crossed. + // * 58.5K → 59K with the model-free upload pair (#748): + // gnubok_create_document_upload + gnubok_complete_document_upload move + // document bytes out of the model context via a signed PUT URL, fixing + // silent base64 corruption on real-size PDFs. Neither tool can be + // search-only: the pair is the primary upload path for harnesses with + // file access, and the legacy inline tool stays listed for clients + // without it. Trimmed first: the create tool's outputSchema was cut to + // upload_id/upload_url/expires_at (method, size cap and echo fields + // moved to description prose) and mime_type made optional on complete; + // the ~360-token remainder is the two tools' wire contract. // Long-term answer to growth is leaning harder on gnubok_search_tools: if this // fires again, prefer trimming descriptions or making a tool opt-in via search // before bumping further. - expect(approxTokens).toBeLessThan(58_500) + expect(approxTokens).toBeLessThan(59_000) }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 0451113c..6d0480e2 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -170,7 +170,12 @@ import { generateInvoiceEmailText, generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' -import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service' +import { + completePendingDocumentUpload, + createPendingDocumentUpload, + uploadDocument, + MAX_DOCUMENT_SIZE, +} from '@/lib/core/documents/document-service' import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' // Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned // pattern as invoice-inbox above: the CI guard only checks lib/, app/api/, @@ -290,6 +295,146 @@ const VALID_VAT_TREATMENTS = [ 'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt', ] as const +const MCP_DOCUMENT_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/webp', +] as const + +const MCP_DOCUMENT_MIME_TYPE_SET = new Set(MCP_DOCUMENT_MIME_TYPES) + +function resolveMcpDocumentMimeType(fileName: string, requestedMimeType: unknown): string { + let mimeType = typeof requestedMimeType === 'string' ? requestedMimeType : undefined + if (!mimeType) { + const extension = fileName.split('.').pop()?.toLowerCase() + const mimeMap: Record = { + pdf: 'application/pdf', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + heic: 'image/heic', + webp: 'image/webp', + } + mimeType = extension ? mimeMap[extension] : undefined + if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${extension}`) + } + if (!MCP_DOCUMENT_MIME_TYPE_SET.has(mimeType)) { + throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`) + } + return mimeType +} + +interface DocumentInboxResult { + document_id: string + inbox_item_id: string + status: string + extracted_data: Record + matched_supplier_id: string | null +} + +async function findCompletedDocumentInboxItem( + supabase: SupabaseClient, + companyId: string, + userId: string, + inboxItemId: string +): Promise { + const { data, error } = await supabase + .from('invoice_inbox_items') + .select('id, document_id, status, extracted_data, matched_supplier_id') + .eq('id', inboxItemId) + .eq('company_id', companyId) + .eq('user_id', userId) + .maybeSingle() + if (error) throw new Error(`Failed to check completed document upload: ${error.message}`) + if (!data) return null + if (data.document_id !== inboxItemId) { + throw new Error('Upload ID collides with an unrelated inbox item') + } + return { + document_id: data.document_id, + inbox_item_id: data.id, + status: data.status, + extracted_data: (data.extracted_data ?? {}) as Record, + matched_supplier_id: data.matched_supplier_id, + } +} + +async function createDocumentInboxItem( + supabase: SupabaseClient, + companyId: string, + userId: string, + documentId: string, + fileName: string, + mimeType: string, + buffer: Buffer, + reservedInboxItemId?: string +): Promise { + if (reservedInboxItemId) { + const existing = await findCompletedDocumentInboxItem( + supabase, + companyId, + userId, + reservedInboxItemId, + ) + if (existing) return existing + } + + const { data: extracted } = await extractInvoiceFields({ buffer, mimeType, fileName }) + + let matchedSupplierId: string | null = null + if (extracted.supplier.orgNumber) { + const { data: supplier } = await supabase + .from('suppliers') + .select('id') + .eq('company_id', companyId) + .eq('org_number', extracted.supplier.orgNumber) + .limit(1) + .maybeSingle() + if (supplier) matchedSupplierId = supplier.id + } + + const { data: inbox, error: inboxError } = await supabase + .from('invoice_inbox_items') + .insert({ + // Literal payload keeps the no-phantom-columns scanner able to resolve + // every column; the legacy path gets an explicit UUID instead of the DB + // default. + id: reservedInboxItemId ?? crypto.randomUUID(), + company_id: companyId, + user_id: userId, + status: 'received', + source: 'upload', + document_id: documentId, + extracted_data: extracted as unknown as Record, + matched_supplier_id: matchedSupplierId, + }) + .select('id, status') + .single() + + if (inboxError) { + if (reservedInboxItemId) { + const concurrent = await findCompletedDocumentInboxItem( + supabase, + companyId, + userId, + reservedInboxItemId, + ) + if (concurrent) return concurrent + } + throw new Error(`Failed to create inbox item: ${inboxError.message}`) + } + + return { + document_id: documentId, + inbox_item_id: inbox.id, + status: inbox.status, + extracted_data: extracted as unknown as Record, + matched_supplier_id: matchedSupplierId, + } +} + // ── Pending operations staging ─────────────────────────────── /** @@ -9072,10 +9217,148 @@ export const tools: McpTool[] = [ // ── Document Inbox Tools ──────────────────────────────────── + { + name: 'gnubok_create_document_upload', + title: 'Create Document Upload', + description: 'Create a short-lived URL for a model-free document upload. PUT the raw file bytes (max 10 MB) to upload_url, then call gnubok_complete_document_upload with the same upload_id and file_name.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + file_name: { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'File name with extension, for example "faktura.pdf"', + }, + mime_type: { + type: 'string', + enum: [...MCP_DOCUMENT_MIME_TYPES], + description: 'MIME type. Optional when it can be inferred from the file extension.', + }, + }, + required: ['file_name'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + upload_id: { type: 'string' }, + upload_url: { type: 'string' }, + expires_at: { type: 'string' }, + }, + required: ['upload_id', 'upload_url', 'expires_at'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const fileName = args.file_name as string + // Validation only: reject unsupported types before handing out a signed + // URL. The resolved value is re-derived identically at complete time. + resolveMcpDocumentMimeType(fileName, args.mime_type) + const uploadId = crypto.randomUUID() + const reservation = await createPendingDocumentUpload( + supabase, + companyId, + userId, + uploadId, + fileName, + ) + return { + upload_id: reservation.uploadId, + upload_url: reservation.signedUrl, + expires_at: reservation.expiresAt, + } + }, + }, + + { + name: 'gnubok_complete_document_upload', + title: 'Complete Document Upload', + description: 'Validate and archive bytes sent to the URL from gnubok_create_document_upload, run AI extraction and create the inbox item. Idempotent: safe to retry with the same upload_id.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + upload_id: { + type: 'string', + format: 'uuid', + description: 'Reserved UUID returned by gnubok_create_document_upload', + }, + file_name: { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The same file name used to create the upload URL', + }, + mime_type: { + type: 'string', + enum: [...MCP_DOCUMENT_MIME_TYPES], + description: 'MIME type. Optional when it can be inferred from the file extension.', + }, + }, + required: ['upload_id', 'file_name'], + }, + outputSchema: { + type: 'object', + additionalProperties: false, + properties: { + document_id: { type: 'string' }, + inbox_item_id: { type: 'string' }, + status: { type: 'string' }, + extracted_data: { type: 'object' }, + matched_supplier_id: { type: 'string' }, + }, + required: ['document_id', 'inbox_item_id', 'status'], + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase) { + const uploadId = args.upload_id as string + const fileName = args.file_name as string + const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type) + + const existingInbox = await findCompletedDocumentInboxItem( + supabase, + companyId, + userId, + uploadId, + ) + if (existingInbox) return existingInbox + + const completed = await completePendingDocumentUpload( + supabase, + companyId, + userId, + uploadId, + fileName, + mimeType, + ) + return createDocumentInboxItem( + supabase, + companyId, + userId, + completed.document.id, + fileName, + mimeType, + Buffer.from(completed.buffer), + uploadId, + ) + }, + }, + { name: 'gnubok_upload_document', title: 'Upload Document to Inbox', - description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs AI field extraction (Bedrock OCR): requires the AI capability.', + description: 'Legacy inline-base64 upload for small files (max 10 MB). Prefer gnubok_create_document_upload so raw bytes bypass the model. Runs AI field extraction: requires the AI capability.', inputSchema: { type: 'object', additionalProperties: false, @@ -9107,28 +9390,7 @@ export const tools: McpTool[] = [ async execute(args, companyId, userId, supabase) { const fileName = args.file_name as string const base64Content = args.file_content_base64 as string - let mimeType = args.mime_type as string | undefined - - if (!mimeType) { - const ext = fileName.split('.').pop()?.toLowerCase() - const mimeMap: Record = { - pdf: 'application/pdf', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - heic: 'image/heic', - webp: 'image/webp', - } - mimeType = ext ? mimeMap[ext] : undefined - if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`) - } - - const allowedMimeTypes = new Set([ - 'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp', - ]) - if (!allowedMimeTypes.has(mimeType)) { - throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`) - } + const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type) const buffer = Buffer.from(base64Content, 'base64') if (buffer.byteLength > MAX_DOCUMENT_SIZE) { @@ -9140,48 +9402,15 @@ export const tools: McpTool[] = [ buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), type: mimeType, }, { upload_source: 'api' }) - - const { data: extracted } = await extractInvoiceFields({ - buffer, - mimeType, + return createDocumentInboxItem( + supabase, + companyId, + userId, + doc.id, fileName, - }) - - let matchedSupplierId: string | null = null - if (extracted.supplier.orgNumber) { - const { data: s } = await supabase - .from('suppliers') - .select('id') - .eq('company_id', companyId) - .eq('org_number', extracted.supplier.orgNumber) - .limit(1) - .maybeSingle() - if (s) matchedSupplierId = s.id - } - - const { data: inbox, error: inboxError } = await supabase - .from('invoice_inbox_items') - .insert({ - company_id: companyId, - user_id: userId, - status: 'received', - source: 'upload', - document_id: doc.id, - extracted_data: extracted as unknown as Record, - matched_supplier_id: matchedSupplierId, - }) - .select('id, status') - .single() - - if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`) - - return { - document_id: doc.id, - inbox_item_id: inbox.id, - status: inbox.status, - extracted_data: extracted, - matched_supplier_id: matchedSupplierId, - } + mimeType, + buffer, + ) }, }, diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index e1d03f37..fbdf214b 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -232,6 +232,8 @@ export const TOOL_SCOPE_MAP: Record = { // Staged bulk retag of posted-line dimensions (dimensions PR6). gnubok_tag_journal_lines: 'bookkeeping:write', // Document inbox + gnubok_create_document_upload: 'transactions:write', + gnubok_complete_document_upload: 'transactions:write', gnubok_upload_document: 'transactions:write', gnubok_list_inbox_items: 'transactions:read', gnubok_get_inbox_item: 'transactions:read', diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts index 4802175a..845e1a13 100644 --- a/lib/core/documents/__tests__/document-service.test.ts +++ b/lib/core/documents/__tests__/document-service.test.ts @@ -29,10 +29,16 @@ function makeClient(storageOverrides: Record = {}) { createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }), from: vi.fn().mockReturnValue({ upload: vi.fn().mockResolvedValue({ data: {}, error: null }), + createSignedUploadUrl: vi.fn().mockResolvedValue({ + data: { signedUrl: 'https://example.com/signed-upload' }, + error: null, + }), download: vi.fn().mockResolvedValue({ data: new Blob(['test content']), error: null, }), + list: vi.fn().mockResolvedValue({ data: [], error: null }), + move: vi.fn().mockResolvedValue({ data: {}, error: null }), remove: vi.fn().mockResolvedValue({ data: [], error: null }), getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://example.com/file.pdf' }, @@ -59,6 +65,14 @@ import { verifyIntegrity, validateDocumentMagicBytes, buildDocumentStoragePath, + buildPendingDocumentStoragePath, + buildReservedDocumentStoragePath, + cleanupExpiredPendingDocumentUploads, + createPendingDocumentUpload, + completePendingDocumentUpload, + computeSHA256, + PENDING_DOCUMENT_UPLOAD_RETENTION_MS, + SIGNED_DOCUMENT_UPLOAD_TTL_MS, isCompanyScopedDocumentPath, companyScopedDocumentPath, legacyDocumentPath, @@ -312,6 +326,163 @@ describe('uploadDocument', () => { }) }) +describe('model-free signed document uploads', () => { + const company = '11111111-1111-4111-8111-111111111111' + const user = '22222222-2222-4222-8222-222222222222' + const uploadId = '33333333-3333-4333-8333-333333333333' + + it('creates a company-scoped signed upload reservation with a two-hour expiry', async () => { + const now = Date.parse('2026-08-03T10:00:00.000Z') + const createSignedUploadUrl = vi.fn().mockResolvedValue({ + data: { signedUrl: 'https://storage.example/upload?token=signed' }, + error: null, + }) + const supabase = makeClient({ createSignedUploadUrl }) + + const reservation = await createPendingDocumentUpload( + supabase as never, + company, + user, + uploadId, + 'Leverantör faktura.pdf', + now, + ) + + expect(createSignedUploadUrl).toHaveBeenCalledWith( + `documents/${company}/${user}/pending/${uploadId}_Leverant_r_faktura.pdf`, + { upsert: false }, + ) + expect(reservation).toEqual({ + uploadId, + signedUrl: 'https://storage.example/upload?token=signed', + expiresAt: new Date(now + SIGNED_DOCUMENT_UPLOAD_TTL_MS).toISOString(), + }) + }) + + it('adopts uploaded bytes under the reserved UUID and permanent WORM path', async () => { + const buffer = pdfBuffer('presigned upload') + const document = makeDocumentAttachment({ + id: uploadId, + user_id: user, + company_id: company, + file_name: 'invoice.pdf', + mime_type: 'application/pdf', + storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'), + sha256_hash: await computeSHA256(buffer), + }) + results = [ + { data: null, error: null }, + { data: document, error: null }, + ] + + const move = vi.fn().mockResolvedValue({ data: {}, error: null }) + const download = vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }) + serviceClientOverride = makeClient({ download, move }) + + const completed = await completePendingDocumentUpload( + makeClient() as never, + company, + user, + uploadId, + 'invoice.pdf', + 'application/pdf', + ) + + expect(completed.document.id).toBe(uploadId) + expect(move).toHaveBeenCalledWith( + buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf'), + buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'), + ) + }) + + it('returns the existing immutable document on retry after verifying its hash', async () => { + const buffer = pdfBuffer('already complete') + const document = makeDocumentAttachment({ + id: uploadId, + user_id: user, + company_id: company, + file_name: 'invoice.pdf', + mime_type: 'application/pdf', + storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf'), + sha256_hash: await computeSHA256(buffer), + }) + results = [{ data: document, error: null }] + + const move = vi.fn().mockResolvedValue({ data: {}, error: null }) + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }), + move, + }) + + const completed = await completePendingDocumentUpload( + makeClient() as never, + company, + user, + uploadId, + 'invoice.pdf', + 'application/pdf', + ) + + expect(completed.document).toEqual(document) + expect(move).not.toHaveBeenCalled() + }) + + it('removes corrupt pending bytes before rejecting completion', async () => { + results = [{ data: null, error: null }] + const pendingPath = buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf') + const remove = vi.fn().mockResolvedValue({ data: [], error: null }) + serviceClientOverride = makeClient({ + download: vi.fn().mockResolvedValue({ data: new Blob(['not a pdf']), error: null }), + remove, + }) + + await expect( + completePendingDocumentUpload( + makeClient() as never, + company, + user, + uploadId, + 'invoice.pdf', + 'application/pdf', + ), + ).rejects.toThrow(/kunde inte verifieras/) + expect(remove).toHaveBeenCalledWith([pendingPath]) + }) + + it('cleans only expired pending objects in a bounded company and user prefix', async () => { + const now = Date.parse('2026-08-03T10:00:00.000Z') + const remove = vi.fn().mockResolvedValue({ data: [], error: null }) + const list = vi.fn().mockResolvedValue({ + data: [ + { + id: 'old-object', + name: `${uploadId}_old.pdf`, + created_at: new Date(now - PENDING_DOCUMENT_UPLOAD_RETENTION_MS - 1).toISOString(), + }, + { + id: 'new-object', + name: `${uploadId}_new.pdf`, + created_at: new Date(now - 60_000).toISOString(), + }, + ], + error: null, + }) + serviceClientOverride = makeClient({ list, remove }) + + const removed = await cleanupExpiredPendingDocumentUploads(company, user, now) + + expect(list).toHaveBeenCalledWith(`documents/${company}/${user}/pending`, { + limit: 100, + offset: 0, + sortBy: { column: 'created_at', order: 'asc' }, + }) + expect(remove).toHaveBeenCalledWith([ + `documents/${company}/${user}/pending/${uploadId}_old.pdf`, + ]) + expect(removed).toBe(1) + }) +}) + describe('createNewVersion', () => { it('increments version and supersedes previous', async () => { const current = makeDocumentAttachment({ @@ -426,6 +597,16 @@ describe('storage key layout helpers', () => { ) }) + it('builds deterministic pending and permanent keys for a reserved upload', () => { + const uploadId = '33333333-3333-4333-8333-333333333333' + expect(buildPendingDocumentStoragePath(company, user, uploadId, 'faktura 1.pdf')).toBe( + `documents/${company}/${user}/pending/${uploadId}_faktura_1.pdf`, + ) + expect(buildReservedDocumentStoragePath(company, user, uploadId, 'faktura 1.pdf')).toBe( + `documents/${company}/${user}/${uploadId}_faktura_1.pdf`, + ) + }) + it('recognises company-scoped keys', () => { expect(isCompanyScopedDocumentPath(`documents/${company}/${user}/1_a.pdf`, company)).toBe(true) expect(isCompanyScopedDocumentPath(`documents/${user}/1_a.pdf`, company)).toBe(false) diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index a8188f3d..ab3240ae 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -56,6 +56,9 @@ function sanitizeFileName(name: string): string { */ export const DOCUMENTS_BUCKET = 'documents' const DOCUMENTS_PATH_ROOT = 'documents' +export const SIGNED_DOCUMENT_UPLOAD_TTL_MS = 2 * 60 * 60 * 1000 +export const PENDING_DOCUMENT_UPLOAD_RETENTION_MS = 24 * 60 * 60 * 1000 +const PENDING_DOCUMENT_UPLOAD_CLEANUP_LIMIT = 100 /** Build a company-scoped storage key for a new upload. */ export function buildDocumentStoragePath( @@ -67,6 +70,26 @@ export function buildDocumentStoragePath( return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/${timestamp}_${sanitizeFileName(fileName)}` } +/** Build the temporary key targeted by a signed, model-free upload. */ +export function buildPendingDocumentStoragePath( + companyId: string, + userId: string, + uploadId: string, + fileName: string +): string { + return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/pending/${uploadId}_${sanitizeFileName(fileName)}` +} + +/** Build the permanent WORM key for a completed signed upload. */ +export function buildReservedDocumentStoragePath( + companyId: string, + userId: string, + uploadId: string, + fileName: string +): string { + return `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/${uploadId}_${sanitizeFileName(fileName)}` +} + /** True when the key already sits under the company-scoped prefix. */ export function isCompanyScopedDocumentPath(storagePath: string, companyId: string): boolean { return storagePath.startsWith(`${DOCUMENTS_PATH_ROOT}/${companyId}/`) @@ -321,6 +344,231 @@ async function ensureDocumentsBucket(): Promise { bucketVerified = true } +/** + * Remove a bounded batch of abandoned signed-upload objects. Pending objects + * are not accounting records and have no document_attachments row. Completed + * documents are moved out of this prefix before the immutable row is created. + */ +export async function cleanupExpiredPendingDocumentUploads( + companyId: string, + userId: string, + now: number = Date.now() +): Promise { + const serviceClient = createServiceClientNoCookies() + const prefix = `${DOCUMENTS_PATH_ROOT}/${companyId}/${userId}/pending` + const storage = serviceClient.storage.from(DOCUMENTS_BUCKET) + const { data, error } = await storage.list(prefix, { + limit: PENDING_DOCUMENT_UPLOAD_CLEANUP_LIMIT, + offset: 0, + sortBy: { column: 'created_at', order: 'asc' }, + }) + if (error || !data) return 0 + + const cutoff = now - PENDING_DOCUMENT_UPLOAD_RETENTION_MS + const expiredPaths = data + .filter((item) => { + if (!item.id || !item.created_at) return false + const createdAt = Date.parse(item.created_at) + return Number.isFinite(createdAt) && createdAt < cutoff + }) + .map((item) => `${prefix}/${item.name}`) + + if (expiredPaths.length === 0) return 0 + const { error: removeError } = await storage.remove(expiredPaths) + return removeError ? 0 : expiredPaths.length +} + +export interface PendingDocumentUploadReservation { + uploadId: string + signedUrl: string + expiresAt: string +} + +/** + * Reserve a company-scoped object key and create a short-lived upload URL. + * The returned URL accepts the raw file bytes via PUT without authentication. + */ +export async function createPendingDocumentUpload( + supabase: SupabaseClient, + companyId: string, + userId: string, + uploadId: string, + fileName: string, + now: number = Date.now() +): Promise { + await ensureDocumentsBucket() + await cleanupExpiredPendingDocumentUploads(companyId, userId, now) + + const storagePath = buildPendingDocumentStoragePath(companyId, userId, uploadId, fileName) + const { data, error } = await supabase.storage + .from(DOCUMENTS_BUCKET) + .createSignedUploadUrl(storagePath, { upsert: false }) + + if (error || !data?.signedUrl) { + throw new Error(`Failed to create document upload URL: ${error?.message ?? 'no URL returned'}`) + } + + return { + uploadId, + signedUrl: data.signedUrl, + expiresAt: new Date(now + SIGNED_DOCUMENT_UPLOAD_TTL_MS).toISOString(), + } +} + +export interface CompletedPendingDocumentUpload { + document: DocumentAttachment + buffer: ArrayBuffer +} + +async function findReservedDocument( + supabase: SupabaseClient, + companyId: string, + userId: string, + uploadId: string +): Promise { + const { data, error } = await supabase + .from('document_attachments') + .select('*') + .eq('id', uploadId) + .eq('company_id', companyId) + .eq('user_id', userId) + .maybeSingle() + if (error) throw new Error(`Failed to check document upload: ${error.message}`) + return data as DocumentAttachment | null +} + +function validateReservedDocumentMetadata( + document: DocumentAttachment, + fileName: string, + mimeType: string +): void { + if (document.file_name !== fileName || document.mime_type !== mimeType) { + throw new Error('Upload ID was already completed with different file metadata') + } +} + +async function validatePendingDocumentBytes( + buffer: ArrayBuffer, + mimeType: string +): Promise { + if (buffer.byteLength === 0) throw new Error('Uploaded file is empty') + if (buffer.byteLength > MAX_DOCUMENT_SIZE) { + throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`) + } + const magicError = validateDocumentMagicBytes(buffer, mimeType) + if (magicError) throw new Error(magicError) + return computeSHA256(buffer) +} + +/** + * Adopt bytes uploaded through a signed URL into the WORM document archive. + * The reserved UUID becomes the document id, making retries and concurrent + * completion calls converge on the same immutable row. + */ +export async function completePendingDocumentUpload( + supabase: SupabaseClient, + companyId: string, + userId: string, + uploadId: string, + fileName: string, + mimeType: string, + now: number = Date.now() +): Promise { + const serviceClient = createServiceClientNoCookies() + const storage = serviceClient.storage.from(DOCUMENTS_BUCKET) + const pendingPath = buildPendingDocumentStoragePath(companyId, userId, uploadId, fileName) + const permanentPath = buildReservedDocumentStoragePath(companyId, userId, uploadId, fileName) + + const existing = await findReservedDocument(supabase, companyId, userId, uploadId) + if (existing) { + validateReservedDocumentMetadata(existing, fileName, mimeType) + const { data, error } = await storage.download(existing.storage_path) + if (error || !data) { + throw new Error(`Failed to read completed document upload: ${error?.message ?? 'no data returned'}`) + } + const buffer = await data.arrayBuffer() + const hash = await validatePendingDocumentBytes(buffer, mimeType) + if (hash !== existing.sha256_hash) throw new Error('Completed document failed its integrity check') + return { document: existing, buffer } + } + + await cleanupExpiredPendingDocumentUploads(companyId, userId, now) + + let sourcePath = pendingPath + let { data: blob, error: downloadError } = await storage.download(pendingPath) + if (downloadError || !blob) { + const permanentDownload = await storage.download(permanentPath) + blob = permanentDownload.data + downloadError = permanentDownload.error + sourcePath = permanentPath + } + if (downloadError || !blob) { + throw new Error('Document upload was not found or has expired. Create a new upload URL and try again.') + } + + const buffer = await blob.arrayBuffer() + let sha256Hash: string + try { + sha256Hash = await validatePendingDocumentBytes(buffer, mimeType) + } catch (error) { + await storage.remove([sourcePath]) + throw error + } + + if (sourcePath === pendingPath) { + const { error: moveError } = await storage.move(pendingPath, permanentPath) + if (moveError) { + const permanentDownload = await storage.download(permanentPath) + if (permanentDownload.error || !permanentDownload.data) { + throw new Error(`Failed to finalize document upload: ${moveError.message}`) + } + } + } + + const { data, error } = await supabase + .from('document_attachments') + .insert({ + id: uploadId, + user_id: userId, + company_id: companyId, + storage_path: permanentPath, + file_name: fileName, + file_size_bytes: buffer.byteLength, + mime_type: mimeType, + sha256_hash: sha256Hash, + version: 1, + is_current_version: true, + uploaded_by: userId, + upload_source: 'api', + digitization_date: new Date(now).toISOString(), + journal_entry_id: null, + journal_entry_line_id: null, + }) + .select() + .single() + + if (error) { + const concurrent = await findReservedDocument(supabase, companyId, userId, uploadId) + if (concurrent) { + validateReservedDocumentMetadata(concurrent, fileName, mimeType) + if (concurrent.sha256_hash !== sha256Hash) { + throw new Error('Upload ID was completed with different file content') + } + return { document: concurrent, buffer } + } + await storage.remove([permanentPath]) + throw new Error(`Failed to create document record: ${error.message}`) + } + + const document = data as DocumentAttachment + await eventBus.emit({ + type: 'document.uploaded', + payload: { document, userId, companyId }, + }) + + return { document, buffer } +} + /** * Compute SHA-256 hash of a file buffer */ diff --git a/lib/entitlements/__tests__/capability-maps.test.ts b/lib/entitlements/__tests__/capability-maps.test.ts index 8c876998..101f7f39 100644 --- a/lib/entitlements/__tests__/capability-maps.test.ts +++ b/lib/entitlements/__tests__/capability-maps.test.ts @@ -15,19 +15,27 @@ import { /** * 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. + * counterpart. The document upload tools run Bedrock OCR inline via + * extractInvoiceFields: they never stage a pending_operation. */ -const DISPATCH_ONLY_MCP_TOOLS = new Set(['gnubok_upload_document']) +const DISPATCH_ONLY_MCP_TOOLS = new Set([ + 'gnubok_upload_document', + 'gnubok_create_document_upload', + 'gnubok_complete_document_upload', +]) describe('MCP_TOOL_CAPABILITY_MAP', () => { - it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tool)', () => { + it('gates exactly the paid MCP tools (3 external-service staging tools + the AI OCR tools)', () => { 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. + // Dispatch-only AI tools: inline Bedrock OCR, no staged operation. The + // signed-URL pair is gated at create AND complete so a free-tier key can + // neither reserve nor finalize a paid extraction. gnubok_upload_document: CAPABILITY.ai, + gnubok_create_document_upload: CAPABILITY.ai, + gnubok_complete_document_upload: CAPABILITY.ai, }) }) diff --git a/lib/entitlements/keys.ts b/lib/entitlements/keys.ts index 8ee20a66..00f8484d 100644 --- a/lib/entitlements/keys.ts +++ b/lib/entitlements/keys.ts @@ -65,17 +65,19 @@ export const PAID_CAPABILITIES: readonly CapabilityKey[] = [ * read/local SKV tools (generate_agi, vat_declaration_validate/status, agi_status) * stay free: the §4 carve-out forbids blocking a statutory filing obligation. * - * 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). + * The document upload tools invoke AI (Bedrock document OCR via + * extractInvoiceFields), so they are gated on CAPABILITY.ai: the same paywall + * the HTTP inbox upload/attach/retry paths enforce. Without these entries a + * free-tier API 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_create_document_upload: CAPABILITY.ai, + gnubok_complete_document_upload: CAPABILITY.ai, gnubok_upload_document: CAPABILITY.ai, } as const