5d7952a01e
* 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>
948 lines
33 KiB
TypeScript
948 lines
33 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||
import { eventBus } from '@/lib/events'
|
||
import type { DocumentAttachment, DocumentUploadSource } from '@/types'
|
||
|
||
/**
|
||
* Document Service - WORM-style document archive
|
||
*
|
||
* Handles document upload with SHA-256 integrity, version chains,
|
||
* and linking to journal entries. Deletion is blocked by DB triggers
|
||
* for documents linked to committed entries.
|
||
*/
|
||
|
||
/**
|
||
* Sanitize a filename for use in Supabase Storage keys.
|
||
* Replaces spaces and non-ASCII characters with underscores,
|
||
* collapses consecutive underscores, and truncates to avoid
|
||
* exceeding Supabase Storage path length limits.
|
||
*/
|
||
function sanitizeFileName(name: string): string {
|
||
const dotIndex = name.lastIndexOf('.')
|
||
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
|
||
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name
|
||
|
||
const sanitizedBase = base
|
||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||
.replace(/_+/g, '_')
|
||
.replace(/^_|_$/g, '')
|
||
.slice(0, 100) || 'file'
|
||
const sanitizedExt = ext.replace(/[^a-zA-Z0-9.]/g, '_')
|
||
|
||
return sanitizedBase + sanitizedExt
|
||
}
|
||
|
||
/**
|
||
* Storage key layout for the `documents` bucket.
|
||
*
|
||
* NEW (company-scoped, written since 20260726092000):
|
||
* documents/{companyId}/{userId}/{timestamp}_{filename}
|
||
*
|
||
* LEGACY (uploader-scoped, written before that migration):
|
||
* documents/{userId}/{timestamp}_{filename}
|
||
*
|
||
* The legacy layout carried no company_id, so the storage RLS policy could
|
||
* only scope on auth.uid(): an ex-member kept direct Storage access to every
|
||
* document they had uploaded even after their company_members row was
|
||
* deleted. The company-scoped layout lets the policy check
|
||
* public.user_company_ids() instead.
|
||
*
|
||
* Both layouts coexist until the Phase B backfill
|
||
* (scripts/backfill-document-storage-paths.ts) has re-homed every legacy
|
||
* object. Read paths must therefore tolerate both: use
|
||
* documentStoragePathCandidates() (or the downloadDocumentObject /
|
||
* createDocumentSignedUrl helpers below) rather than trusting the stored
|
||
* pointer to be the only key that resolves.
|
||
*/
|
||
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(
|
||
companyId: string,
|
||
userId: string,
|
||
fileName: string,
|
||
timestamp: number = Date.now()
|
||
): string {
|
||
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}/`)
|
||
}
|
||
|
||
/**
|
||
* Translate a legacy `documents/{userId}/...` key into its company-scoped
|
||
* equivalent. Returns null when the key is not in the legacy layout (already
|
||
* company-scoped, or one of the non-document shapes the bucket also holds,
|
||
* e.g. the MCP audit-package `{userId}/audit-packages/...` keys).
|
||
*/
|
||
export function companyScopedDocumentPath(
|
||
storagePath: string,
|
||
companyId: string
|
||
): string | null {
|
||
if (isCompanyScopedDocumentPath(storagePath, companyId)) return null
|
||
const prefix = `${DOCUMENTS_PATH_ROOT}/`
|
||
if (!storagePath.startsWith(prefix)) return null
|
||
return `${prefix}${companyId}/${storagePath.slice(prefix.length)}`
|
||
}
|
||
|
||
/**
|
||
* Translate a company-scoped key back into its legacy `documents/{userId}/...`
|
||
* equivalent. Returns null when the key is not company-scoped.
|
||
*/
|
||
export function legacyDocumentPath(storagePath: string, companyId: string): string | null {
|
||
const prefix = `${DOCUMENTS_PATH_ROOT}/${companyId}/`
|
||
if (!storagePath.startsWith(prefix)) return null
|
||
return `${DOCUMENTS_PATH_ROOT}/${storagePath.slice(prefix.length)}`
|
||
}
|
||
|
||
/**
|
||
* Every key that could hold the bytes for a document row, most likely first.
|
||
* The stored pointer always wins; the alternate layout is the fallback for
|
||
* the window in which the Phase B backfill has moved (or not yet moved) an
|
||
* object relative to the DB pointer.
|
||
*/
|
||
export function documentStoragePathCandidates(
|
||
storagePath: string,
|
||
companyId: string | null | undefined
|
||
): string[] {
|
||
const candidates = [storagePath]
|
||
if (companyId) {
|
||
const alternate =
|
||
companyScopedDocumentPath(storagePath, companyId) ??
|
||
legacyDocumentPath(storagePath, companyId)
|
||
if (alternate && alternate !== storagePath) candidates.push(alternate)
|
||
}
|
||
return candidates
|
||
}
|
||
|
||
type StorageErrorLike = { message?: string } | null
|
||
|
||
/**
|
||
* Download a document object, tolerating both key layouts.
|
||
*
|
||
* Returns the resolved key alongside the blob so callers can log or repair
|
||
* a stale `document_attachments.storage_path` pointer. When every candidate
|
||
* fails, the FIRST error is returned: it refers to the stored pointer, which
|
||
* is the actionable one.
|
||
*/
|
||
export async function downloadDocumentObject(
|
||
supabase: SupabaseClient,
|
||
storagePath: string,
|
||
companyId: string | null | undefined
|
||
): Promise<{ blob: Blob | null; error: StorageErrorLike; resolvedPath: string | null }> {
|
||
let firstError: StorageErrorLike = null
|
||
for (const candidate of documentStoragePathCandidates(storagePath, companyId)) {
|
||
const { data, error } = await supabase.storage.from(DOCUMENTS_BUCKET).download(candidate)
|
||
if (!error && data) {
|
||
return { blob: data as Blob, error: null, resolvedPath: candidate }
|
||
}
|
||
firstError ??= (error as StorageErrorLike) ?? { message: 'download returned no data' }
|
||
}
|
||
return { blob: null, error: firstError, resolvedPath: null }
|
||
}
|
||
|
||
/**
|
||
* Create a signed URL for a document object, tolerating both key layouts.
|
||
* Same fallback contract as downloadDocumentObject().
|
||
*/
|
||
export async function createDocumentSignedUrl(
|
||
supabase: SupabaseClient,
|
||
storagePath: string,
|
||
companyId: string | null | undefined,
|
||
expiresInSeconds: number
|
||
): Promise<{ signedUrl: string | null; error: StorageErrorLike; resolvedPath: string | null }> {
|
||
let firstError: StorageErrorLike = null
|
||
for (const candidate of documentStoragePathCandidates(storagePath, companyId)) {
|
||
const { data, error } = await supabase.storage
|
||
.from(DOCUMENTS_BUCKET)
|
||
.createSignedUrl(candidate, expiresInSeconds)
|
||
if (!error && data?.signedUrl) {
|
||
return { signedUrl: data.signedUrl, error: null, resolvedPath: candidate }
|
||
}
|
||
firstError ??= (error as StorageErrorLike) ?? { message: 'createSignedUrl returned no data' }
|
||
}
|
||
return { signedUrl: null, error: firstError, resolvedPath: null }
|
||
}
|
||
|
||
export const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10 MB
|
||
export const ALLOWED_DOCUMENT_TYPES = [
|
||
'application/pdf',
|
||
'image/jpeg',
|
||
'image/png',
|
||
'image/webp',
|
||
]
|
||
|
||
/**
|
||
* Validate file size and MIME type before upload.
|
||
* Returns an error string or null if valid.
|
||
*/
|
||
export function validateDocumentFile(file: { size: number; type?: string }): string | null {
|
||
if (file.size === 0) {
|
||
return 'Filen är tom'
|
||
}
|
||
if (file.size > MAX_DOCUMENT_SIZE) {
|
||
return `Filen är för stor (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`
|
||
}
|
||
if (!file.type || !ALLOWED_DOCUMENT_TYPES.includes(file.type)) {
|
||
return 'Otillåten filtyp. Tillåtna: PDF, JPG, PNG, WebP.'
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Inspect the first bytes of a buffer to identify the actual file format.
|
||
* Defends against callers (typically MCP agents) that base64-encode a text
|
||
* placeholder or summary instead of the real binary file: those uploads
|
||
* succeed at the storage layer but the bytes are unreadable as a PDF/image.
|
||
*/
|
||
export function detectFileMagic(bytes: Uint8Array): string | null {
|
||
if (bytes.length < 4) return null
|
||
// PDF: %PDF- anywhere in the first 1024 bytes. ISO 32000 readers accept a
|
||
// preamble before the header (Acrobat scans the first 1 KB), and real-world
|
||
// invoice PDFs arrive with leading newlines/junk: requiring offset 0
|
||
// rejected files every normal reader opens. Image types stay strict at
|
||
// offset 0: genuine image files always start with their signature, and the
|
||
// looseness is not needed there to keep the anti-placeholder defense tight.
|
||
const pdfScanEnd = Math.min(bytes.length - 5, 1024)
|
||
for (let i = 0; i <= pdfScanEnd; i++) {
|
||
if (
|
||
bytes[i] === 0x25 &&
|
||
bytes[i + 1] === 0x50 &&
|
||
bytes[i + 2] === 0x44 &&
|
||
bytes[i + 3] === 0x46 &&
|
||
bytes[i + 4] === 0x2D
|
||
) return 'application/pdf'
|
||
}
|
||
// PNG: 89 50 4E 47
|
||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||
// JPEG: FF D8 FF
|
||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
|
||
// WebP: RIFF<4-byte size>WEBP
|
||
if (
|
||
bytes.length >= 12 &&
|
||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||
) return 'image/webp'
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* XHTML/XML has no binary magic number. For the declared type
|
||
* application/xhtml+xml (system-generated iXBRL årsredovisningar) we instead
|
||
* require the content to start with an XML declaration, an HTML doctype, or
|
||
* an <html> root element (after an optional UTF-8 BOM and leading
|
||
* whitespace). This branch is consulted ONLY for that declared type: it
|
||
* never loosens detection for PDF/PNG/JPEG/WEBP uploads.
|
||
*/
|
||
function looksLikeXhtml(bytes: Uint8Array): boolean {
|
||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||
const head = Buffer.from(bytes.slice(offset, offset + 256))
|
||
.toString('utf8')
|
||
.replace(/^[\s]+/, '')
|
||
.toLowerCase()
|
||
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
|
||
}
|
||
|
||
/**
|
||
* JSON has no binary magic number either. For the declared type
|
||
* application/json (raw PSD2 responses archived as räkenskapsinformation per
|
||
* BFL 7 kap) the content must parse as JSON with an object or array root — a
|
||
* prose placeholder is not valid JSON, and a bare quoted string still fails
|
||
* the root check, so the anti-placeholder defense stays intact. Consulted
|
||
* ONLY for that declared type.
|
||
*/
|
||
function looksLikeJson(bytes: Uint8Array): boolean {
|
||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||
try {
|
||
const parsed = JSON.parse(Buffer.from(bytes.slice(offset)).toString('utf8'))
|
||
return typeof parsed === 'object' && parsed !== null
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verify the buffer actually contains a file of the declared type.
|
||
* Returns an error string or null if valid. HEIC has many ftyp brands so
|
||
* we skip the check for now: the UI path doesn't allow HEIC anyway, only
|
||
* the MCP upload tool does, and corrupted HEIC has not been observed.
|
||
*/
|
||
export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
|
||
if (declaredMimeType === 'image/heic') return null
|
||
if (declaredMimeType === 'application/xhtml+xml') {
|
||
if (looksLikeXhtml(new Uint8Array(buffer))) return null
|
||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
|
||
}
|
||
if (declaredMimeType === 'application/json') {
|
||
if (looksLikeJson(new Uint8Array(buffer))) return null
|
||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett giltigt JSON-dokument.`
|
||
}
|
||
const detected = detectFileMagic(new Uint8Array(buffer))
|
||
if (!detected) {
|
||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil: vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
|
||
}
|
||
if (detected !== declaredMimeType) {
|
||
return `Filinnehållet matchar inte den angivna filtypen (förväntade ${declaredMimeType}, hittade ${detected}).`
|
||
}
|
||
return null
|
||
}
|
||
|
||
let bucketVerified = false
|
||
|
||
/** @internal Reset bucket verification flag: for testing only */
|
||
export function _resetBucketVerified() {
|
||
bucketVerified = false
|
||
}
|
||
|
||
/**
|
||
* Ensure the 'documents' storage bucket exists, creating it if missing.
|
||
* Runs once per process lifetime (same pattern as ensureInitialized).
|
||
*
|
||
* Uses a cookieless service-role client for bucket admin operations
|
||
* (getBucket/createBucket require service-role). This avoids the cookie
|
||
* dependency that hangs in API-key auth contexts (e.g. MCP server).
|
||
*/
|
||
async function ensureDocumentsBucket(): Promise<void> {
|
||
if (bucketVerified) return
|
||
|
||
const serviceClient = createServiceClientNoCookies()
|
||
const { data: bucket } = await serviceClient.storage.getBucket('documents')
|
||
|
||
if (!bucket) {
|
||
await serviceClient.storage.createBucket('documents', {
|
||
public: false,
|
||
fileSizeLimit: 52428800, // 50 MB
|
||
})
|
||
}
|
||
|
||
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
|
||
*/
|
||
export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
}
|
||
|
||
/**
|
||
* Upload a document and create a record with SHA-256 integrity hash
|
||
*/
|
||
export async function uploadDocument(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
file: { name: string; buffer: ArrayBuffer; type?: string },
|
||
metadata: {
|
||
upload_source?: DocumentUploadSource
|
||
journal_entry_id?: string
|
||
journal_entry_line_id?: string
|
||
} = {}
|
||
): Promise<DocumentAttachment> {
|
||
await ensureDocumentsBucket()
|
||
|
||
// Reject corrupt uploads at the boundary: see validateDocumentMagicBytes.
|
||
if (file.type) {
|
||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||
if (magicError) throw new Error(magicError)
|
||
}
|
||
|
||
// Compute SHA-256 hash
|
||
const sha256Hash = await computeSHA256(file.buffer)
|
||
|
||
// Company-scoped storage key: the tenant id must be IN the key so the
|
||
// storage RLS policy can revoke access when a membership is removed.
|
||
const storagePath = buildDocumentStoragePath(companyId, userId, file.name)
|
||
|
||
// Upload to Supabase Storage
|
||
const { error: uploadError } = await supabase.storage
|
||
.from('documents')
|
||
.upload(storagePath, file.buffer, {
|
||
contentType: file.type || 'application/octet-stream',
|
||
upsert: false,
|
||
})
|
||
|
||
if (uploadError) {
|
||
throw new Error(`Failed to upload document: ${uploadError.message}`)
|
||
}
|
||
|
||
// Create document record
|
||
const { data, error } = await supabase
|
||
.from('document_attachments')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
storage_path: storagePath,
|
||
file_name: file.name,
|
||
file_size_bytes: file.buffer.byteLength,
|
||
mime_type: file.type || null,
|
||
sha256_hash: sha256Hash,
|
||
version: 1,
|
||
is_current_version: true,
|
||
uploaded_by: userId,
|
||
upload_source: metadata.upload_source || 'file_upload',
|
||
digitization_date: new Date().toISOString(),
|
||
journal_entry_id: metadata.journal_entry_id || null,
|
||
journal_entry_line_id: metadata.journal_entry_line_id || null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
// Clean up the just-uploaded object on record creation failure. The
|
||
// documents bucket is WORM by design: storage.objects has NO DELETE
|
||
// policy, so remove() on the caller's cookie-bound client is silently
|
||
// blocked by RLS (it reports success without deleting anything) and the
|
||
// object would linger as an orphan. Only the service role can actually
|
||
// remove it. Authorization: the key was built by this very call for the
|
||
// caller's own failed upload, and no DB row references it.
|
||
await createServiceClientNoCookies()
|
||
.storage.from(DOCUMENTS_BUCKET)
|
||
.remove([storagePath])
|
||
throw new Error(`Failed to create document record: ${error.message}`)
|
||
}
|
||
|
||
const result = data as DocumentAttachment
|
||
|
||
await eventBus.emit({
|
||
type: 'document.uploaded',
|
||
payload: { document: result, userId, companyId },
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Create a new version of an existing document (WORM: old version is superseded)
|
||
*
|
||
* Uses the create_document_version RPC for atomic versioning with:
|
||
* - Row-level locking (prevents concurrent versioning race condition)
|
||
* - Cryptographic hash chain (prev_version_hash links to previous version)
|
||
* - Single transaction (insert new + mark old superseded)
|
||
*/
|
||
export async function createNewVersion(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
originalId: string,
|
||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||
): Promise<DocumentAttachment> {
|
||
await ensureDocumentsBucket()
|
||
|
||
if (file.type) {
|
||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||
if (magicError) throw new Error(magicError)
|
||
}
|
||
|
||
// Compute SHA-256 hash
|
||
const sha256Hash = await computeSHA256(file.buffer)
|
||
|
||
// The new version must land under the SAME company prefix as the document
|
||
// it supersedes. The caller (POST /api/documents/:id/versions) does not
|
||
// pass a companyId, so resolve it from the original row: the read goes
|
||
// through the user-scoped client, so RLS already blocks a cross-tenant id,
|
||
// and create_document_version re-checks membership server-side.
|
||
const { data: original, error: originalError } = await supabase
|
||
.from('document_attachments')
|
||
.select('company_id')
|
||
.eq('id', originalId)
|
||
.maybeSingle()
|
||
|
||
if (originalError || !original?.company_id) {
|
||
throw new Error('Failed to create new version: original document not found')
|
||
}
|
||
|
||
// Upload new file to Storage
|
||
const storagePath = buildDocumentStoragePath(
|
||
original.company_id as string,
|
||
userId,
|
||
file.name
|
||
)
|
||
|
||
const { error: uploadError } = await supabase.storage
|
||
.from('documents')
|
||
.upload(storagePath, file.buffer, {
|
||
contentType: file.type || 'application/octet-stream',
|
||
upsert: false,
|
||
})
|
||
|
||
if (uploadError) {
|
||
throw new Error(`Failed to upload new version: ${uploadError.message}`)
|
||
}
|
||
|
||
// Atomic version creation via RPC (row lock + hash chain + supersede in one tx)
|
||
const { data: newDocId, error: rpcError } = await supabase.rpc('create_document_version', {
|
||
p_user_id: userId,
|
||
p_original_doc_id: originalId,
|
||
p_storage_path: storagePath,
|
||
p_file_name: file.name,
|
||
p_file_size_bytes: file.buffer.byteLength,
|
||
p_mime_type: file.type || null,
|
||
p_sha256_hash: sha256Hash,
|
||
})
|
||
|
||
if (rpcError) {
|
||
// Clean up the uploaded file on RPC failure. Service-role client for the
|
||
// same reason as in uploadDocument: the WORM bucket has no DELETE policy,
|
||
// so a caller-bound remove() is silently blocked by RLS and the object
|
||
// would be orphaned. The original-document fetch above (user-scoped, RLS)
|
||
// plus the failed RPC are the authorization context; the key was created
|
||
// by this call and nothing references it.
|
||
await createServiceClientNoCookies()
|
||
.storage.from(DOCUMENTS_BUCKET)
|
||
.remove([storagePath])
|
||
throw new Error(`Failed to create new version: ${rpcError.message}`)
|
||
}
|
||
|
||
// Fetch the complete new version record
|
||
const { data: newDoc, error: fetchError } = await supabase
|
||
.from('document_attachments')
|
||
.select('*')
|
||
.eq('id', newDocId)
|
||
.single()
|
||
|
||
if (fetchError || !newDoc) {
|
||
throw new Error('Failed to fetch new version record')
|
||
}
|
||
|
||
return newDoc as DocumentAttachment
|
||
}
|
||
|
||
/**
|
||
* Link an existing document to a journal entry
|
||
*/
|
||
export async function linkToJournalEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string,
|
||
journalEntryId: string,
|
||
journalEntryLineId?: string
|
||
): Promise<DocumentAttachment> {
|
||
// The document is company-filtered below, but the journal entry id arrives
|
||
// from the client and the FK only requires existence: verify it belongs to
|
||
// the same company so a crafted id can't anchor a document to another
|
||
// tenant's verifikation. (RLS hides foreign rows either way; this makes the
|
||
// rejection explicit instead of a confusing downstream state.)
|
||
const { data: entry, error: entryError } = await supabase
|
||
.from('journal_entries')
|
||
.select('id')
|
||
.eq('id', journalEntryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (entryError || !entry) {
|
||
throw new Error('Failed to link document: journal entry not found')
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('document_attachments')
|
||
.update({
|
||
journal_entry_id: journalEntryId,
|
||
journal_entry_line_id: journalEntryLineId || null,
|
||
})
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
throw new Error(`Failed to link document: ${error.message}`)
|
||
}
|
||
|
||
return data as DocumentAttachment
|
||
}
|
||
|
||
export type DeleteDocumentResult =
|
||
| { ok: true; document: Pick<DocumentAttachment, 'id' | 'file_name'> }
|
||
| { ok: false; reason: 'not_found' | 'linked_to_entry'; status: number; message: string }
|
||
|
||
/**
|
||
* Delete a document if and only if it is not yet linked to a journal entry.
|
||
*
|
||
* BFL 7 kap 2§: once a document is attached to a verifikation it becomes
|
||
* räkenskapsinformation and may not be deleted within the 7-year retention
|
||
* window. Linked docs must be superseded via createNewVersion() instead.
|
||
* The block_document_deletion() trigger is the DB-level backstop.
|
||
*/
|
||
export async function deleteDocument(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string
|
||
): Promise<DeleteDocumentResult> {
|
||
const { data: doc, error: fetchError } = await supabase
|
||
.from('document_attachments')
|
||
.select('id, file_name, storage_path, journal_entry_id, user_id')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (fetchError || !doc) {
|
||
return {
|
||
ok: false,
|
||
reason: 'not_found',
|
||
status: 404,
|
||
message: 'Underlaget hittades inte.',
|
||
}
|
||
}
|
||
|
||
if (doc.journal_entry_id) {
|
||
return {
|
||
ok: false,
|
||
reason: 'linked_to_entry',
|
||
status: 409,
|
||
message:
|
||
'Underlaget är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation ska bevaras i minst 7 år och får inte raderas. Använd "Ersätt med ny version" om underlaget behöver korrigeras.',
|
||
}
|
||
}
|
||
|
||
const { error: deleteError } = await supabase
|
||
.from('document_attachments')
|
||
.delete()
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (deleteError) {
|
||
const msg = (deleteError as { message?: string }).message ?? ''
|
||
if (msg.includes('Bokföringslagen') || msg.includes('retention')) {
|
||
return {
|
||
ok: false,
|
||
reason: 'linked_to_entry',
|
||
status: 409,
|
||
message:
|
||
'Underlaget kan inte tas bort på grund av Bokföringslagens bevarandekrav (7 kap 2§).',
|
||
}
|
||
}
|
||
throw new Error(`Failed to delete document: ${msg}`)
|
||
}
|
||
|
||
if (doc.storage_path) {
|
||
// Remove BOTH key layouts. During the Phase B backfill a document can
|
||
// briefly exist under the legacy and the company-scoped key at once;
|
||
// removing only the stored pointer would leave a readable orphan copy of
|
||
// a document the user asked to erase.
|
||
//
|
||
// The removal runs on the service-role client: the documents bucket is
|
||
// WORM by design (storage.objects has no DELETE policy), so the caller's
|
||
// cookie-bound client is silently blocked by RLS and remove() reports
|
||
// success while both objects survive, readable by every company member
|
||
// under the company-scoped SELECT policy. Authorization already happened
|
||
// above: the company-filtered row fetch plus the row delete that just
|
||
// succeeded (with block_document_deletion() as the DB-level backstop).
|
||
await createServiceClientNoCookies()
|
||
.storage.from(DOCUMENTS_BUCKET)
|
||
.remove(documentStoragePathCandidates(doc.storage_path, companyId))
|
||
}
|
||
|
||
await eventBus.emit({
|
||
type: 'document.deleted',
|
||
payload: {
|
||
document: { id: doc.id, file_name: doc.file_name },
|
||
userId: doc.user_id,
|
||
companyId,
|
||
},
|
||
})
|
||
|
||
return { ok: true, document: { id: doc.id, file_name: doc.file_name } }
|
||
}
|
||
|
||
/**
|
||
* Verify document integrity by re-hashing and comparing
|
||
*/
|
||
export async function verifyIntegrity(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string
|
||
): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
|
||
|
||
// Fetch document record
|
||
const { data: doc, error: docError } = await supabase
|
||
.from('document_attachments')
|
||
.select('storage_path, sha256_hash')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (docError || !doc) {
|
||
throw new Error('Document not found')
|
||
}
|
||
|
||
// Download via the service-role client: the storage SELECT policy only
|
||
// covers the uploader's own folder (documents/{uid}/...), so a caller-bound
|
||
// client cannot read colleague-uploaded files. The company-filtered row
|
||
// fetch above (RLS on document_attachments) is the authorization. The
|
||
// helper tolerates the legacy and the company-scoped key layout while the
|
||
// Phase B backfill is in flight.
|
||
const serviceClient = createServiceClientNoCookies()
|
||
const { blob: fileData, error: downloadError } = await downloadDocumentObject(
|
||
serviceClient,
|
||
doc.storage_path,
|
||
companyId
|
||
)
|
||
|
||
if (downloadError || !fileData) {
|
||
throw new Error(`Failed to download document: ${downloadError?.message}`)
|
||
}
|
||
|
||
// Re-compute hash
|
||
const buffer = await fileData.arrayBuffer()
|
||
const computedHash = await computeSHA256(buffer)
|
||
|
||
return {
|
||
valid: computedHash === doc.sha256_hash,
|
||
storedHash: doc.sha256_hash,
|
||
computedHash,
|
||
}
|
||
}
|