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' /** * 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' // HEIC/HEIF (ISO-BMFF): bytes 4-7 spell 'ftyp'; the brand at bytes 8-11 // names the container flavor. Brands outside the two image families // (mp4, mov, ...) stay undetected on purpose. if ( bytes.length >= 12 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70 ) { const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]) if (HEIC_BRANDS.has(brand)) return 'image/heic' if (HEIF_BRANDS.has(brand)) return 'image/heif' } return null } // ISO-BMFF ftyp brands for still images. The HEVC-coded variants (single // image, image sequence, and their extended forms) all read as image/heic; // the codec-agnostic MIAF brands read as image/heif. iOS labels the same // capture with either declared type, so validateDocumentMagicBytes accepts // the two families interchangeably. const HEIC_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs']) const HEIF_BRANDS = new Set(['mif1', 'msf1']) const HEIC_FAMILY = new Set(['image/heic', 'image/heif']) /** * 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 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(' { 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 { 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 { /** `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 /** * 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, 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 || !sameStoredMimeType(document.mime_type, mimeType)) { throw new Error('Upload ID was already completed with different file metadata') } } /** * 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 { if (buffer.byteLength === 0) { throw Object.assign(new Error('Uploaded file is empty'), { code: 'DOC_UPLOAD_EMPTY' }) } if (buffer.byteLength > MAX_DOCUMENT_SIZE) { 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 Object.assign(new Error(magicError), { code: 'DOC_UPLOAD_INVALID_CONTENT', messageSv: 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(), options: CompletePendingDocumentUploadOptions = {} ): 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) { // 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() let sha256Hash: string try { sha256Hash = await validatePendingDocumentBytes(buffer, mimeType) } catch (error) { await storage.remove([sourcePath]) throw error } // Persist what the bytes are, not what the client said (see // resolveStoredMimeType). The storage object itself keeps the metadata // the PUT declared: nothing serving from this app trusts it. const storedMimeType = resolveStoredMimeType(buffer, mimeType) 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) { 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: storedMimeType, sha256_hash: sha256Hash, version: 1, is_current_version: true, uploaded_by: userId, upload_source: options.uploadSource ?? '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]) // 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 await eventBus.emit({ type: 'document.uploaded', payload: { document, userId, companyId, ...(options.extractionOwner ? { extractionOwner: options.extractionOwner } : {}), }, }) return { document, buffer } } /** * Compute SHA-256 hash of a file buffer */ export async function computeSHA256(buffer: ArrayBuffer): Promise { 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('') } async function deterministicDocumentId( companyId: string, idempotencyKey: string, sha256Hash: string, ): Promise { const input = new TextEncoder().encode(`${companyId}\u0000${idempotencyKey}\u0000${sha256Hash}`) const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', input)) const bytes = digest.slice(0, 16) bytes[6] = (bytes[6] & 0x0f) | 0x50 bytes[8] = (bytes[8] & 0x3f) | 0x80 const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } /** * 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 idempotency_key?: string /** * Content dedupe for intake channels: before storing, look for a * current-version document in the same company with the same SHA-256 and * return it (marked `deduplicated`) instead of archiving a copy. Opt-in, * because archival callers (sent invoices, filings, bank exports) must * store what they produced even when the bytes repeat. SELECT-then-insert * leaves a small concurrent-upload race, accepted exactly as in the * WhatsApp intake precedent: the loser stores a copy, nothing corrupts. */ dedupeByContent?: boolean /** * Who runs AI extraction on this document. The invoice inbox extracts * the documents it ingests itself (and mirrors the result onto the * document row), so it declares ownership here and the * document-extraction extension yields. 'none' opts out entirely (the * caller already knows the booking). Default: the extension extracts. */ extractionOwner?: DocumentExtractionOwner } = {} ): Promise { 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) } // Stored and stamped type is the validated one, never the raw client type. const storedMimeType = resolveStoredMimeType(file.buffer, file.type) // Compute SHA-256 hash const sha256Hash = await computeSHA256(file.buffer) if (metadata.dedupeByContent) { // Oldest current-version match wins so repeated deliveries keep // converging on the same archived original. Pre-dedupe data can hold // several identical documents, hence limit(1) rather than maybeSingle. const { data: existing, 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) { // Fail closed: treating a broken lookup as "no match" would archive // the duplicate this flag exists to prevent, silently, on every // transient DB error. Intake callers (webhooks, sweeps) retry. throw new Error(`Content dedupe lookup failed: ${dedupeError.message}`) } const hit = (existing as DocumentAttachment[] | null)?.[0] if (hit) return { ...hit, deduplicated: true } } // Callers importing immutable third-party records can provide a stable // scope. The resulting row UUID makes the database primary key the atomic // claim for (company, scope, content), so concurrent serverless requests // converge without requiring a process-local lock. const reservedDocumentId = metadata.idempotency_key ? await deterministicDocumentId(companyId, metadata.idempotency_key, sha256Hash) : null // 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. // Idempotent calls use a unique object key per attempt. Their deterministic // document row, not Storage, arbitrates the race; the losing object is then // removed with the service role before this function returns. const storagePath = reservedDocumentId ? buildReservedDocumentStoragePath(companyId, userId, crypto.randomUUID(), file.name) : buildDocumentStoragePath(companyId, userId, file.name) // Upload to Supabase Storage const { error: uploadError } = await supabase.storage .from('documents') .upload(storagePath, file.buffer, { contentType: storedMimeType ?? '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({ id: reservedDocumentId ?? crypto.randomUUID(), user_id: userId, company_id: companyId, storage_path: storagePath, file_name: file.name, file_size_bytes: file.buffer.byteLength, mime_type: storedMimeType, 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) { if (reservedDocumentId && error.code === '23505') { // The column list mirrors the DocumentAttachment interface one for one, // so the row this returns is the stored row and nothing else. // last_integrity_check_at stays in it even though it is now legacy // (migration 20260901130000 moved the verification stamp to // document_integrity_checks, and nothing writes this column any more): // this branch re-reads a row a concurrent request inserted seconds ago, // where the column is NULL by construction, and no caller interprets the // value. Dropping it would leave the returned object short of a key the // type declares; joining the new ledger for it would fetch a check that // cannot exist yet. Whoever wants "when was this document last verified" // reads document_integrity_checks, never this field. const { data: concurrent, error: concurrentError } = await supabase .from('document_attachments') .select('id, user_id, company_id, storage_path, file_name, file_size_bytes, mime_type, sha256_hash, version, original_id, superseded_by_id, is_current_version, uploaded_by, upload_source, digitization_date, journal_entry_id, journal_entry_line_id, prev_version_hash, last_integrity_check_at, created_at, updated_at') .eq('id', reservedDocumentId) .eq('company_id', companyId) .maybeSingle() if (!concurrentError && concurrent) { const existing = concurrent as DocumentAttachment await createServiceClientNoCookies() .storage.from(DOCUMENTS_BUCKET) .remove([storagePath]) if ( existing.sha256_hash !== sha256Hash || existing.journal_entry_id !== (metadata.journal_entry_id || null) ) { throw new Error('Idempotency key was already used for different document metadata') } return existing } } // 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, ...(metadata.extractionOwner ? { extractionOwner: metadata.extractionOwner } : {}), }, }) 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 { await ensureDocumentsBucket() if (file.type) { const magicError = validateDocumentMagicBytes(file.buffer, file.type) if (magicError) throw new Error(magicError) } const storedMimeType = resolveStoredMimeType(file.buffer, file.type) // 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: storedMimeType ?? '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: storedMimeType, 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 { // 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 } | { 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 { 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, } }