feat(mcp): model-free document upload via signed URL (#1378)

* feat(mcp): model-free document upload via signed URL (#748)

Adds gnubok_create_document_upload + gnubok_complete_document_upload so
document bytes reach storage through a short-lived signed PUT URL and
never pass through the model context. Fixes silent base64 corruption on
real-size PDFs and the context blowup on batch uploads.

- pending/ staage keys with TTL cleanup; completion validates magic
  bytes + SHA-256, moves bytes to the WORM key and adopts the reserved
  UUID as document id, making retries and concurrent completions
  idempotent
- legacy gnubok_upload_document kept for clients without file access,
  description now points to the signed-URL pair; shared mime resolution
  and inbox-item creation extracted
- both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and
  MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold
- payload guard ceiling 58.5K to 59K after trimming the create tool's
  outputSchema to upload_id/upload_url/expires_at

Fixes #748

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): satisfy capability-map lock and phantom-column scanner

The exact-entries lock in capability-maps.test.ts now includes the
signed-URL pair as dispatch-only AI tools, and the inbox insert uses a
literal payload (explicit UUID instead of a conditional spread) so the
no-phantom-columns scanner can resolve every column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:41:05 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 00d4c8a49e
commit 5d7952a01e
9 changed files with 946 additions and 85 deletions
@@ -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)
@@ -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<typeof import('@/lib/core/documents/document-service')>()
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<string, unknown> = {}
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<string, unknown> }
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')
}
})
})
@@ -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)
})
})
+294 -65
View File
@@ -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<string>(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<string, string> = {
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<string, unknown>
matched_supplier_id: string | null
}
async function findCompletedDocumentInboxItem(
supabase: SupabaseClient,
companyId: string,
userId: string,
inboxItemId: string
): Promise<DocumentInboxResult | null> {
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<string, unknown>,
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<DocumentInboxResult> {
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<string, unknown>,
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<string, unknown>,
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<string, string> = {
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<string, unknown>,
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,
)
},
},
+2
View File
@@ -232,6 +232,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// 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',
@@ -29,10 +29,16 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
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)
+248
View File
@@ -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<void> {
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<number> {
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<PendingDocumentUploadReservation> {
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<DocumentAttachment | null> {
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<string> {
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<CompletedPendingDocumentUpload> {
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
*/
@@ -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<string>(['gnubok_upload_document'])
const DISPATCH_ONLY_MCP_TOOLS = new Set<string>([
'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,
})
})
+7 -5
View File
@@ -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<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_create_document_upload: CAPABILITY.ai,
gnubok_complete_document_upload: CAPABILITY.ai,
gnubok_upload_document: CAPABILITY.ai,
} as const