feat(inbox): direct-to-storage upload for files over the hosted body limit (#1551) (#2030)

Hosted uploads larger than the 4 MB multipart ceiling (Vercel's 4.5 MB request-body cap) now go POST /upload/create (signed PUT URL, rate-limited) -> PUT to the raw Storage URL -> POST /upload/complete (server-side magic-byte and size validation, sha256, WORM move, idempotent), reusing the #1378 pending-upload primitives. uploadAndExtract is split into uploadDocument + processArchivedDocument so both paths share the inbox pipeline. Dokumentinkorgen and the supplier-invoice form use the new path only above the threshold; files that fit keep the multipart route. Cap stays at 10 MB (the issue asks for 20 MB: founder call). Refs #1551
This commit is contained in:
Jakob Wennberg
2026-08-30 11:55:42 +02:00
committed by GitHub
parent 523fba0419
commit 749f90fe62
14 changed files with 1391 additions and 53 deletions
@@ -696,10 +696,158 @@ describe('model-free signed document uploads', () => {
'invoice.pdf',
'application/pdf',
),
).rejects.toThrow(/kunde inte verifieras/)
).rejects.toMatchObject({
code: 'DOC_UPLOAD_INVALID_CONTENT',
message: expect.stringMatching(/kunde inte verifieras/),
messageSv: expect.stringMatching(/kunde inte verifieras/),
})
expect(remove).toHaveBeenCalledWith([pendingPath])
})
it('codes an empty pending object as DOC_UPLOAD_EMPTY and removes it', 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([]), error: null }),
remove,
})
await expect(
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
).rejects.toMatchObject({ code: 'DOC_UPLOAD_EMPTY' })
expect(remove).toHaveBeenCalledWith([pendingPath])
})
// The insert payload of a completion: from() call #0 is findReservedDocument,
// #1 the insert (no dedupe lookup in between unless opted in).
function insertPayloadOf(client: ReturnType<typeof makeClient>, fromIndex: number) {
const builder = client.from.mock.results[fromIndex]?.value as { insert: ReturnType<typeof vi.fn> }
return builder.insert.mock.calls[0]?.[0] as Record<string, unknown> | undefined
}
it("stamps upload_source 'api' by default (the MCP tools' provenance)", async () => {
const buffer = pdfBuffer('api upload')
const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) })
results = [
{ data: null, error: null },
{ data: document, error: null },
]
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
})
const client = makeClient()
await completePendingDocumentUpload(client as never, company, user, uploadId, 'invoice.pdf', 'application/pdf')
expect(insertPayloadOf(client, 1)?.upload_source).toBe('api')
})
it("stamps upload_source 'file_upload' for the browser direct-to-storage path", async () => {
const buffer = pdfBuffer('browser upload')
const document = makeDocumentAttachment({ id: uploadId, sha256_hash: await computeSHA256(buffer) })
results = [
{ data: null, error: null },
{ data: document, error: null },
]
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
})
const client = makeClient()
await completePendingDocumentUpload(
client as never,
company,
user,
uploadId,
'invoice.pdf',
'application/pdf',
undefined,
{ uploadSource: 'file_upload' },
)
expect(insertPayloadOf(client, 1)?.upload_source).toBe('file_upload')
// No content-dedupe lookup unless asked for: exactly two from() calls.
expect(client.from).toHaveBeenCalledTimes(2)
})
it('opt-in content dedupe returns the existing document and removes the pending object', async () => {
const buffer = pdfBuffer('already archived')
const existingId = '55555555-5555-4555-8555-555555555555'
const existing = makeDocumentAttachment({
id: existingId,
company_id: company,
sha256_hash: await computeSHA256(buffer),
})
results = [
{ data: null, error: null }, // findReservedDocument
{ data: [existing], error: null }, // dedupe lookup
]
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
move,
remove,
})
const client = makeClient()
const completed = await completePendingDocumentUpload(
client as never,
company,
user,
uploadId,
'invoice.pdf',
'application/pdf',
undefined,
{ dedupeByContent: true },
)
expect(completed.document.id).toBe(existingId)
expect(completed.document.deduplicated).toBe(true)
expect(remove).toHaveBeenCalledWith([buildPendingDocumentStoragePath(company, user, uploadId, 'invoice.pdf')])
expect(move).not.toHaveBeenCalled()
// findReservedDocument + dedupe lookup, and never an insert
expect(client.from).toHaveBeenCalledTimes(2)
})
it('keeps the SQLSTATE on a rejected document insert so callers can map an RLS denial', async () => {
const buffer = pdfBuffer('viewer upload')
results = [
{ data: null, error: null }, // findReservedDocument
{
data: null,
error: {
code: '42501',
message: 'new row violates row-level security policy for table "document_attachments"',
},
},
{ data: null, error: null }, // concurrent-completion check
]
const remove = vi.fn().mockResolvedValue({ data: [], error: null })
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
remove,
})
await expect(
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
).rejects.toMatchObject({ code: '42501' })
// The moved object is taken back out: no row, no orphan.
expect(remove).toHaveBeenCalledWith([buildReservedDocumentStoragePath(company, user, uploadId, 'invoice.pdf')])
})
it('codes a missing or expired reservation as DOCUMENT_UPLOAD_NOT_FOUND', async () => {
results = [{ data: null, error: null }]
serviceClientOverride = makeClient({
download: vi.fn().mockResolvedValue({ data: null, error: { message: 'Object not found' } }),
})
await expect(
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'invoice.pdf', 'application/pdf'),
).rejects.toMatchObject({ code: 'DOCUMENT_UPLOAD_NOT_FOUND' })
})
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 })
+76 -8
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { dbError } from '@/lib/errors/db-error'
import { eventBus } from '@/lib/events'
import type { DocumentExtractionOwner } from '@/lib/events/types'
import type { DocumentAttachment, DocumentUploadSource } from '@/types'
@@ -464,10 +465,32 @@ export async function createPendingDocumentUpload(
}
export interface CompletedPendingDocumentUpload {
document: DocumentAttachment
/** `deduplicated` is set only when the caller opted into content dedupe
* and the company had already archived these exact bytes. */
document: DocumentAttachment & { deduplicated?: boolean }
buffer: ArrayBuffer
}
export interface CompletePendingDocumentUploadOptions {
extractionOwner?: DocumentExtractionOwner
/**
* Provenance stamped on the document row. Default 'api': the signed-URL
* primitives were built for MCP agents. The browser direct-to-storage path
* (files too large for a hosted function body) passes 'file_upload' so the
* archive tells the same story as the multipart route it replaces.
*/
uploadSource?: Extract<DocumentUploadSource, 'api' | 'file_upload'>
/**
* Content dedupe, same contract as uploadDocument({ dedupeByContent }):
* after hashing, a current-version document in the same company with the
* same SHA-256 wins, the pending object is removed and the existing row is
* returned with `deduplicated: true`. Opt-in (default false) because the
* MCP tools key their idempotency on document id === upload id: a dedupe
* hit would return a different id and trip their collision guard.
*/
dedupeByContent?: boolean
}
async function findReservedDocument(
supabase: SupabaseClient,
companyId: string,
@@ -495,16 +518,32 @@ function validateReservedDocumentMetadata(
}
}
/**
* Each verdict carries a registry code (structured-errors.ts) so a REST
* caller can answer with the right status and copy instead of a generic
* failure. The magic-byte sentence is authored Swedish user copy naming the
* expected and detected types: it rides along as `messageSv` so the route
* can show it without forwarding a raw error message.
*/
async function validatePendingDocumentBytes(
buffer: ArrayBuffer,
mimeType: string
): Promise<string> {
if (buffer.byteLength === 0) throw new Error('Uploaded file is empty')
if (buffer.byteLength === 0) {
throw Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' })
}
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
throw Object.assign(new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`), {
code: 'DOC_UPLOAD_TOO_LARGE',
})
}
const magicError = validateDocumentMagicBytes(buffer, mimeType)
if (magicError) throw new Error(magicError)
if (magicError) {
throw Object.assign(new Error(magicError), {
code: 'DOC_UPLOAD_INVALID_CONTENT',
messageSv: magicError,
})
}
return computeSHA256(buffer)
}
@@ -521,7 +560,7 @@ export async function completePendingDocumentUpload(
fileName: string,
mimeType: string,
now: number = Date.now(),
options: { extractionOwner?: DocumentExtractionOwner } = {}
options: CompletePendingDocumentUploadOptions = {}
): Promise<CompletedPendingDocumentUpload> {
const serviceClient = createServiceClientNoCookies()
const storage = serviceClient.storage.from(DOCUMENTS_BUCKET)
@@ -552,7 +591,12 @@ export async function completePendingDocumentUpload(
sourcePath = permanentPath
}
if (downloadError || !blob) {
throw new Error('Document upload was not found or has expired. Create a new upload URL and try again.')
// Coded so REST callers can answer 404 with the registry copy: the
// browser PUT never landed, or the reservation outlived its TTL.
throw Object.assign(
new Error('Document upload was not found or has expired. Create a new upload URL and try again.'),
{ code: 'DOCUMENT_UPLOAD_NOT_FOUND' },
)
}
const buffer = await blob.arrayBuffer()
@@ -564,6 +608,25 @@ export async function completePendingDocumentUpload(
throw error
}
if (options.dedupeByContent) {
// Same lookup as uploadDocument: oldest current-version match wins, and
// a broken lookup fails closed rather than archiving the duplicate.
const { data: existingByContent, error: dedupeError } = await supabase
.from('document_attachments')
.select('*')
.eq('company_id', companyId)
.eq('sha256_hash', sha256Hash)
.eq('is_current_version', true)
.order('created_at', { ascending: true })
.limit(1)
if (dedupeError) throw dbError(dedupeError, 'Content dedupe lookup failed')
const hit = (existingByContent as DocumentAttachment[] | null)?.[0]
if (hit) {
await storage.remove([sourcePath])
return { document: { ...hit, deduplicated: true }, buffer }
}
}
if (sourcePath === pendingPath) {
const { error: moveError } = await storage.move(pendingPath, permanentPath)
if (moveError) {
@@ -588,7 +651,7 @@ export async function completePendingDocumentUpload(
version: 1,
is_current_version: true,
uploaded_by: userId,
upload_source: 'api',
upload_source: options.uploadSource ?? 'api',
digitization_date: new Date(now).toISOString(),
journal_entry_id: null,
journal_entry_line_id: null,
@@ -606,7 +669,12 @@ export async function completePendingDocumentUpload(
return { document: concurrent, buffer }
}
await storage.remove([permanentPath])
throw new Error(`Failed to create document record: ${error.message}`)
// dbError keeps the SQLSTATE on the thrown error: a viewer-role member
// passes the storage policy (membership only) but not the
// document_attachments insert policy (writers only), and 42501 is what
// lets the caller answer "no permission" in Swedish instead of a generic
// failure.
throw dbError(error, 'Failed to create document record')
}
const document = data as DocumentAttachment