@@ -93,7 +93,7 @@ import {
9393} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
9494import { deleteFile } from '@/lib/uploads/core/storage-service'
9595import {
96- deleteFileMetadata ,
96+ deleteFileMetadataByIdentity ,
9797 type FileMetadataRecord ,
9898 getFileMetadataByKeys ,
9999} from '@/lib/uploads/server/metadata'
@@ -132,28 +132,65 @@ export class KnowledgeBaseFileOwnershipError extends Error {
132132 * referenced bindings are resolved in one query (no N+1 inside the `FOR UPDATE`
133133 * window). Single-document callers pass a one-element array.
134134 */
135+ function getKnowledgeBaseStorageKeys ( fileUrls : readonly string [ ] ) : string [ ] {
136+ return [
137+ ...new Set (
138+ fileUrls
139+ . map ( ( url ) => getKnowledgeBaseStorageKey ( url ) )
140+ . filter ( ( key ) : key is string => typeof key === 'string' && key . startsWith ( 'kb/' ) )
141+ ) ,
142+ ]
143+ }
144+
145+ function getWorkspaceSourceStorageKeys ( fileUrls : readonly string [ ] ) : string [ ] {
146+ return [
147+ ...new Set (
148+ fileUrls
149+ . map ( ( url ) => getKnowledgeBaseStorageKey ( url ) )
150+ . filter ( ( key ) : key is string => typeof key === 'string' && key . startsWith ( 'workspace/' ) )
151+ ) ,
152+ ]
153+ }
154+
155+ async function loadKnowledgeBaseFileBindings (
156+ fileUrls : readonly string [ ] ,
157+ executor : DbExecutor = db
158+ ) : Promise < Map < string , FileMetadataRecord > > {
159+ const keys = getKnowledgeBaseStorageKeys ( fileUrls )
160+ const bindings =
161+ keys . length > 0 ? await getFileMetadataByKeys ( keys , 'knowledge-base' , executor ) : [ ]
162+
163+ return new Map ( bindings . map ( ( binding ) => [ binding . key , binding ] ) )
164+ }
165+
166+ async function loadWorkspaceSourceFileBindings (
167+ fileUrls : readonly string [ ] ,
168+ executor : DbExecutor = db
169+ ) : Promise < Map < string , FileMetadataRecord > > {
170+ const keys = getWorkspaceSourceStorageKeys ( fileUrls )
171+ if ( keys . length === 0 ) return new Map ( )
172+
173+ const workspaceBindings = await getFileMetadataByKeys ( keys , 'workspace' , executor )
174+ const mothershipBindings = await getFileMetadataByKeys ( keys , 'mothership' , executor )
175+
176+ return new Map (
177+ [ ...workspaceBindings , ...mothershipBindings ] . map ( ( binding ) => [ binding . key , binding ] )
178+ )
179+ }
180+
135181async function assertKnowledgeBaseFileUrlsOwnership (
136182 fileUrls : string [ ] ,
137183 kbWorkspaceId : string | null ,
138184 kbUserId : string ,
139185 requestId : string ,
140186 executor : DbExecutor = db
141187) : Promise < Map < string , FileMetadataRecord > > {
142- const keys = [
143- ...new Set (
144- fileUrls
145- . map ( ( url ) => getKnowledgeBaseStorageKey ( url ) )
146- . filter ( ( key ) : key is string => typeof key === 'string' && key . startsWith ( 'kb/' ) )
147- ) ,
148- ]
188+ const keys = getKnowledgeBaseStorageKeys ( fileUrls )
149189 if ( keys . length === 0 ) {
150190 return new Map ( )
151191 }
152192
153- // Read bindings on the caller's transaction so the security check shares the
154- // same connection/lock context as the FOR UPDATE'd insert that follows.
155- const bindings = await getFileMetadataByKeys ( keys , 'knowledge-base' , executor )
156- const bindingByKey = new Map ( bindings . map ( ( binding ) => [ binding . key , binding ] ) )
193+ const bindingByKey = await loadKnowledgeBaseFileBindings ( fileUrls , executor )
157194
158195 for ( const key of keys ) {
159196 const binding = bindingByKey . get ( key )
@@ -189,6 +226,21 @@ async function assertKnowledgeBaseFileUrlsOwnership(
189226 return bindingByKey
190227}
191228
229+ async function loadCurrentWorkspaceSourceFileSecretProvenance ( options : {
230+ fileUrl : string
231+ } ) : Promise < DurableSecretProvenance | undefined > {
232+ const storageKey = getKnowledgeBaseStorageKey ( options . fileUrl )
233+ if ( ! storageKey ?. startsWith ( 'workspace/' ) ) return undefined
234+
235+ const bindingByKey = await loadWorkspaceSourceFileBindings ( [ options . fileUrl ] )
236+ const binding = bindingByKey . get ( storageKey )
237+ if ( ! binding ) return undefined
238+
239+ const provenanceById = await getBoundWorkspaceFileSecretProvenanceByMetadata ( db , [ binding ] )
240+ const provenance = provenanceById . get ( binding . id ) ?? { status : 'unknown' as const }
241+ return durableSecretProvenanceFromWorkspaceFile ( provenance , binding )
242+ }
243+
192244const TIMEOUTS = {
193245 OVERALL_PROCESSING : envNumber ( env . KB_CONFIG_MAX_DURATION , 600 ) * 1000 ,
194246} as const
@@ -722,6 +774,10 @@ export async function processDocumentAsync(
722774 embeddingModel : knowledgeBase . embeddingModel ,
723775 billedAccountUserId : workspaceTable . billedAccountUserId ,
724776 uploadedBy : document . uploadedBy ,
777+ filename : document . filename ,
778+ fileUrl : document . fileUrl ,
779+ fileSize : document . fileSize ,
780+ mimeType : document . mimeType ,
725781 tag1 : document . tag1 ,
726782 tag2 : document . tag2 ,
727783 tag3 : document . tag3 ,
@@ -773,6 +829,12 @@ export async function processDocumentAsync(
773829 }
774830
775831 const ctx = contextRows [ 0 ]
832+ const persistedDocData = {
833+ filename : ctx . filename ,
834+ fileUrl : ctx . fileUrl ,
835+ fileSize : ctx . fileSize ,
836+ mimeType : ctx . mimeType ,
837+ }
776838
777839 await db
778840 . update ( document )
@@ -861,19 +923,26 @@ export async function processDocumentAsync(
861923 let embeddingModelName = kbEmbeddingModel
862924 let embeddingPricingId = kbEmbeddingModel
863925
864- const documentSecretContext = await loadKnowledgeDocumentSecretRegistry ( documentId , {
865- userId : documentActorUserId ,
866- ...( ctx . workspaceId ? { workspaceId : ctx . workspaceId } : { } ) ,
926+ const currentSourceFileProvenance = await loadCurrentWorkspaceSourceFileSecretProvenance ( {
927+ fileUrl : persistedDocData . fileUrl ,
867928 } )
929+ const documentSecretContext = await loadKnowledgeDocumentSecretRegistry (
930+ documentId ,
931+ {
932+ userId : documentActorUserId ,
933+ ...( ctx . workspaceId ? { workspaceId : ctx . workspaceId } : { } ) ,
934+ } ,
935+ currentSourceFileProvenance
936+ )
868937
869938 await withTimeout (
870939 runWithKnowledgeModelInputProvenance (
871940 documentSecretContext . registry ,
872941 async ( ) => {
873942 const processed = await processDocument (
874- docData . fileUrl ,
875- docData . filename ,
876- docData . mimeType ,
943+ persistedDocData . fileUrl ,
944+ persistedDocData . filename ,
945+ persistedDocData . mimeType ,
877946 kbConfig . maxSize ,
878947 kbConfig . overlap ,
879948 kbConfig . minSize ,
@@ -1171,15 +1240,9 @@ function getServerKnownDocumentSize(
11711240async function resolveServerKnownDocumentSizes <
11721241 T extends { readonly fileUrl : string ; readonly fileSize : number } ,
11731242> ( documents : readonly T [ ] ) : Promise < Array < T & { fileSize : number } > > {
1174- const keys = [
1175- ...new Set (
1176- documents
1177- . map ( ( docData ) => getKnowledgeBaseStorageKey ( docData . fileUrl ) )
1178- . filter ( ( key ) : key is string => typeof key === 'string' && key . startsWith ( 'kb/' ) )
1179- ) ,
1180- ]
1181- const bindings = keys . length > 0 ? await getFileMetadataByKeys ( keys , 'knowledge-base' ) : [ ]
1182- const bindingByKey = new Map ( bindings . map ( ( binding ) => [ binding . key , binding ] ) )
1243+ const bindingByKey = await loadKnowledgeBaseFileBindings (
1244+ documents . map ( ( document ) => document . fileUrl )
1245+ )
11831246 return documents . map ( ( docData ) => ( {
11841247 ...docData ,
11851248 fileSize : getServerKnownDocumentSize ( docData . fileUrl , docData . fileSize , bindingByKey ) ,
@@ -1302,9 +1365,14 @@ export async function createDocumentRecords(
13021365 requestId ,
13031366 tx
13041367 )
1305- const trackedBindings = [ ...bindingByKey . values ( ) ] . filter (
1306- ( binding ) => binding . secretProvenanceVersion !== null
1368+ const sourceBindingByKey = await loadWorkspaceSourceFileBindings (
1369+ resolvedDocuments . map ( ( docData ) => docData . fileUrl ) ,
1370+ tx
13071371 )
1372+ const trackedBindings = [
1373+ ...[ ...bindingByKey . values ( ) ] . filter ( ( binding ) => binding . secretProvenanceVersion !== null ) ,
1374+ ...sourceBindingByKey . values ( ) ,
1375+ ]
13081376 const boundFileProvenanceById = await getBoundWorkspaceFileSecretProvenanceByMetadata (
13091377 tx ,
13101378 trackedBindings
@@ -1411,17 +1479,22 @@ export async function createDocumentRecords(
14111479 boolean3 : processedTags . boolean3 ?? null ,
14121480 }
14131481 const source = createKnowledgeDocumentSourceValue ( baseDocument )
1414- const binding = storageKey ? bindingByKey . get ( storageKey ) : undefined
1415- const boundFileProvenance =
1416- binding && binding . secretProvenanceVersion !== null
1417- ? ( boundFileProvenanceById . get ( binding . id ) ?? { status : 'unknown' as const } )
1482+ const binding = storageKey
1483+ ? ( bindingByKey . get ( storageKey ) ?? sourceBindingByKey . get ( storageKey ) )
1484+ : undefined
1485+ const provenanceBinding =
1486+ binding && ( binding . secretProvenanceVersion !== null || sourceBindingByKey . has ( binding . key ) )
1487+ ? binding
14181488 : undefined
1489+ const boundFileProvenance = provenanceBinding
1490+ ? ( boundFileProvenanceById . get ( provenanceBinding . id ) ?? { status : 'unknown' as const } )
1491+ : undefined
14191492 const provenance = bindKnowledgeDocumentWriteSecretProvenance ( {
14201493 source,
14211494 provenance : secretProvenances ?. [ documentIndex ] ,
14221495 tagDefinitions,
1423- ...( binding && boundFileProvenance
1424- ? { boundFile : { binding, provenance : boundFileProvenance } }
1496+ ...( provenanceBinding && boundFileProvenance
1497+ ? { boundFile : { binding : provenanceBinding , provenance : boundFileProvenance } }
14251498 : { } ) ,
14261499 } )
14271500 const newDocument = {
@@ -1833,11 +1906,21 @@ export async function createSingleDocument(
18331906 requestId ,
18341907 tx
18351908 )
1909+ const sourceBindingByKey = await loadWorkspaceSourceFileBindings (
1910+ [ resolvedDocumentData . fileUrl ] ,
1911+ tx
1912+ )
18361913 const storageKey = getKnowledgeBaseStorageKey ( resolvedDocumentData . fileUrl )
1837- const binding = storageKey ? bindingByKey . get ( storageKey ) : undefined
1914+ const binding = storageKey
1915+ ? ( bindingByKey . get ( storageKey ) ?? sourceBindingByKey . get ( storageKey ) )
1916+ : undefined
1917+ const provenanceBinding =
1918+ binding && ( binding . secretProvenanceVersion !== null || sourceBindingByKey . has ( binding . key ) )
1919+ ? binding
1920+ : undefined
18381921 const boundFileProvenanceById = await getBoundWorkspaceFileSecretProvenanceByMetadata (
18391922 tx ,
1840- binding && binding . secretProvenanceVersion !== null ? [ binding ] : [ ]
1923+ provenanceBinding ? [ provenanceBinding ] : [ ]
18411924 )
18421925 const currentSize = getServerKnownDocumentSize (
18431926 resolvedDocumentData . fileUrl ,
@@ -1873,16 +1956,15 @@ export async function createSingleDocument(
18731956 }
18741957
18751958 const source = createKnowledgeDocumentSourceValue ( newDocument )
1876- const boundFileProvenance =
1877- binding && binding . secretProvenanceVersion !== null
1878- ? ( boundFileProvenanceById . get ( binding . id ) ?? { status : 'unknown' as const } )
1879- : undefined
1959+ const boundFileProvenance = provenanceBinding
1960+ ? ( boundFileProvenanceById . get ( provenanceBinding . id ) ?? { status : 'unknown' as const } )
1961+ : undefined
18801962 const documentProvenance = bindKnowledgeDocumentWriteSecretProvenance ( {
18811963 source,
18821964 provenance : secretProvenance ,
18831965 tagDefinitions,
1884- ...( binding && boundFileProvenance
1885- ? { boundFile : { binding, provenance : boundFileProvenance } }
1966+ ...( provenanceBinding && boundFileProvenance
1967+ ? { boundFile : { binding : provenanceBinding , provenance : boundFileProvenance } }
18861968 : { } ) ,
18871969 } )
18881970 await tx . insert ( document ) . values ( {
@@ -2464,20 +2546,18 @@ export async function deleteDocumentStorageFiles(
24642546 storageKey : getKnowledgeBaseStorageKey ( doc . fileUrl ) ,
24652547 } ) )
24662548
2467- // Resolve all kb/ ownership bindings in one query (avoids an N+1 across the
2468- // delete fan-out below).
2469- const kbKeys = [
2549+ const storageKeys = [
24702550 ...new Set (
24712551 entries
24722552 . map ( ( entry ) => entry . storageKey )
2473- . filter ( ( key ) : key is string => typeof key === 'string' && key . startsWith ( 'kb/' ) )
2553+ . filter ( ( key ) : key is string => typeof key === 'string' )
24742554 ) ,
24752555 ]
2476- const ownerByKey = new Map < string , string | null > ( )
2477- if ( kbKeys . length > 0 ) {
2478- const bindings = await getFileMetadataByKeys ( kbKeys , 'knowledge-base' )
2556+ const bindingByKey = new Map < string , FileMetadataRecord > ( )
2557+ if ( storageKeys . length > 0 ) {
2558+ const bindings = await getFileMetadataByKeys ( storageKeys , 'knowledge-base' )
24792559 for ( const binding of bindings ) {
2480- ownerByKey . set ( binding . key , binding . workspaceId )
2560+ bindingByKey . set ( binding . key , binding )
24812561 }
24822562 }
24832563
@@ -2486,32 +2566,32 @@ export async function deleteDocumentStorageFiles(
24862566 return
24872567 }
24882568
2489- // Only delete a kb/ object when its trusted ownership binding confirms the
2490- // deleting document's workspace owns it. Prevents deleting another tenant's
2491- // object via a document with a planted fileUrl.
2492- if ( storageKey . startsWith ( 'kb/' ) ) {
2493- const bindingWorkspaceId = ownerByKey . get ( storageKey )
2494- if ( ! bindingWorkspaceId ) {
2495- logger . warn ( `[${ requestId } ] Skipping storage delete: no ownership binding for key` , {
2496- documentId : doc . id ,
2497- storageKey,
2498- } )
2499- return
2500- }
2501- if ( ! doc . workspaceId || bindingWorkspaceId !== doc . workspaceId ) {
2502- logger . warn ( `[${ requestId } ] Skipping storage delete: ownership binding mismatch` , {
2503- documentId : doc . id ,
2504- storageKey,
2505- bindingWorkspaceId,
2506- documentWorkspaceId : doc . workspaceId ?? null ,
2507- } )
2508- return
2509- }
2569+ const binding = bindingByKey . get ( storageKey )
2570+ if ( ! binding ?. workspaceId ) {
2571+ logger . warn ( `[${ requestId } ] Skipping storage delete: no ownership binding for key` , {
2572+ documentId : doc . id ,
2573+ storageKey,
2574+ } )
2575+ return
2576+ }
2577+ if ( ! doc . workspaceId || binding . workspaceId !== doc . workspaceId ) {
2578+ logger . warn ( `[${ requestId } ] Skipping storage delete: ownership binding mismatch` , {
2579+ documentId : doc . id ,
2580+ storageKey,
2581+ bindingWorkspaceId : binding . workspaceId ,
2582+ documentWorkspaceId : doc . workspaceId ?? null ,
2583+ } )
2584+ return
25102585 }
25112586
25122587 try {
25132588 await deleteFile ( { key : storageKey , context : 'knowledge-base' } )
2514- await deleteFileMetadata ( storageKey )
2589+ await deleteFileMetadataByIdentity ( {
2590+ id : binding . id ,
2591+ key : binding . key ,
2592+ context : binding . context ,
2593+ contentUpdatedAt : binding . contentUpdatedAt ,
2594+ } )
25152595 } catch ( error ) {
25162596 logger . warn ( `[${ requestId } ] Failed to delete document storage file` , {
25172597 documentId : doc . id ,
0 commit comments