hazo_files 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,12 +47,17 @@ __export(index_exports, {
47
47
  GoogleDriveAuth: () => GoogleDriveAuth,
48
48
  GoogleDriveModule: () => GoogleDriveModule,
49
49
  HAZO_FILES_DEFAULT_TABLE_NAME: () => HAZO_FILES_DEFAULT_TABLE_NAME,
50
+ HAZO_FILES_JOB_TYPES: () => HAZO_FILES_JOB_TYPES,
50
51
  HAZO_FILES_MIGRATION_V2: () => HAZO_FILES_MIGRATION_V2,
51
52
  HAZO_FILES_MIGRATION_V3: () => HAZO_FILES_MIGRATION_V3,
53
+ HAZO_FILES_MIGRATION_V4: () => HAZO_FILES_MIGRATION_V4,
52
54
  HAZO_FILES_NAMING_DEFAULT_TABLE_NAME: () => HAZO_FILES_NAMING_DEFAULT_TABLE_NAME,
53
55
  HAZO_FILES_NAMING_TABLE_SCHEMA: () => HAZO_FILES_NAMING_TABLE_SCHEMA,
54
56
  HAZO_FILES_TABLE_SCHEMA: () => HAZO_FILES_TABLE_SCHEMA,
57
+ HAZO_FILE_QUOTAS_DEFAULT_TABLE_NAME: () => HAZO_FILE_QUOTAS_DEFAULT_TABLE_NAME,
58
+ HAZO_FILE_QUOTAS_TABLE_SCHEMA: () => HAZO_FILE_QUOTAS_TABLE_SCHEMA,
55
59
  HazoFilesError: () => HazoFilesError,
60
+ ImportSizeCapError: () => ImportSizeCapError,
56
61
  InvalidExtensionError: () => InvalidExtensionError,
57
62
  InvalidPathError: () => InvalidPathError,
58
63
  LLMExtractionService: () => LLMExtractionService,
@@ -60,6 +65,9 @@ __export(index_exports, {
60
65
  NamingConventionService: () => NamingConventionService,
61
66
  OperationError: () => OperationError,
62
67
  PermissionDeniedError: () => PermissionDeniedError,
68
+ QuotaExceededError: () => QuotaExceededError,
69
+ QuotaService: () => QuotaService,
70
+ SSRFError: () => SSRFError,
63
71
  SYSTEM_COUNTER_VARIABLES: () => SYSTEM_COUNTER_VARIABLES,
64
72
  SYSTEM_DATE_VARIABLES: () => SYSTEM_DATE_VARIABLES,
65
73
  SYSTEM_FILE_VARIABLES: () => SYSTEM_FILE_VARIABLES,
@@ -95,6 +103,8 @@ __export(index_exports, {
95
103
  createLocalModule: () => createLocalModule,
96
104
  createModule: () => createModule,
97
105
  createNamingConventionService: () => createNamingConventionService,
106
+ createPurgeJobHandlers: () => createPurgeJobHandlers,
107
+ createQuotaService: () => createQuotaService,
98
108
  createTrackedFileManager: () => createTrackedFileManager,
99
109
  createUploadExtractService: () => createUploadExtractService,
100
110
  createVariableSegment: () => createVariableSegment,
@@ -123,6 +133,7 @@ __export(index_exports, {
123
133
  getMergedData: () => getMergedData,
124
134
  getMigrationForTable: () => getMigrationForTable,
125
135
  getMigrationV3ForTable: () => getMigrationV3ForTable,
136
+ getMigrationV4ForTable: () => getMigrationV4ForTable,
126
137
  getMimeType: () => getMimeType,
127
138
  getNameWithoutExtension: () => getNameWithoutExtension,
128
139
  getNamingSchemaForTable: () => getNamingSchemaForTable,
@@ -183,6 +194,11 @@ __export(index_exports, {
183
194
  });
184
195
  module.exports = __toCommonJS(index_exports);
185
196
 
197
+ // src/services/tracked-file-manager.ts
198
+ var fs3 = __toESM(require("fs"));
199
+ var os = __toESM(require("os"));
200
+ var path3 = __toESM(require("path"));
201
+
186
202
  // src/config/index.ts
187
203
  var ini = __toESM(require("ini"));
188
204
  var fs = __toESM(require("fs"));
@@ -350,63 +366,63 @@ var HazoFilesError = class extends Error {
350
366
  }
351
367
  };
352
368
  var FileNotFoundError = class extends HazoFilesError {
353
- constructor(path3) {
354
- super(`File not found: ${path3}`, "FILE_NOT_FOUND", { path: path3 });
369
+ constructor(path4) {
370
+ super(`File not found: ${path4}`, "FILE_NOT_FOUND", { path: path4 });
355
371
  this.name = "FileNotFoundError";
356
372
  }
357
373
  };
358
374
  var DirectoryNotFoundError = class extends HazoFilesError {
359
- constructor(path3) {
360
- super(`Directory not found: ${path3}`, "DIRECTORY_NOT_FOUND", { path: path3 });
375
+ constructor(path4) {
376
+ super(`Directory not found: ${path4}`, "DIRECTORY_NOT_FOUND", { path: path4 });
361
377
  this.name = "DirectoryNotFoundError";
362
378
  }
363
379
  };
364
380
  var FileExistsError = class extends HazoFilesError {
365
- constructor(path3) {
366
- super(`File already exists: ${path3}`, "FILE_EXISTS", { path: path3 });
381
+ constructor(path4) {
382
+ super(`File already exists: ${path4}`, "FILE_EXISTS", { path: path4 });
367
383
  this.name = "FileExistsError";
368
384
  }
369
385
  };
370
386
  var DirectoryExistsError = class extends HazoFilesError {
371
- constructor(path3) {
372
- super(`Directory already exists: ${path3}`, "DIRECTORY_EXISTS", { path: path3 });
387
+ constructor(path4) {
388
+ super(`Directory already exists: ${path4}`, "DIRECTORY_EXISTS", { path: path4 });
373
389
  this.name = "DirectoryExistsError";
374
390
  }
375
391
  };
376
392
  var DirectoryNotEmptyError = class extends HazoFilesError {
377
- constructor(path3) {
378
- super(`Directory is not empty: ${path3}`, "DIRECTORY_NOT_EMPTY", { path: path3 });
393
+ constructor(path4) {
394
+ super(`Directory is not empty: ${path4}`, "DIRECTORY_NOT_EMPTY", { path: path4 });
379
395
  this.name = "DirectoryNotEmptyError";
380
396
  }
381
397
  };
382
398
  var PermissionDeniedError = class extends HazoFilesError {
383
- constructor(path3, operation) {
384
- super(`Permission denied for ${operation} on: ${path3}`, "PERMISSION_DENIED", { path: path3, operation });
399
+ constructor(path4, operation) {
400
+ super(`Permission denied for ${operation} on: ${path4}`, "PERMISSION_DENIED", { path: path4, operation });
385
401
  this.name = "PermissionDeniedError";
386
402
  }
387
403
  };
388
404
  var InvalidPathError = class extends HazoFilesError {
389
- constructor(path3, reason) {
390
- super(`Invalid path "${path3}": ${reason}`, "INVALID_PATH", { path: path3, reason });
405
+ constructor(path4, reason) {
406
+ super(`Invalid path "${path4}": ${reason}`, "INVALID_PATH", { path: path4, reason });
391
407
  this.name = "InvalidPathError";
392
408
  }
393
409
  };
394
410
  var FileTooLargeError = class extends HazoFilesError {
395
- constructor(path3, size, maxSize) {
411
+ constructor(path4, size, maxSize) {
396
412
  super(
397
- `File "${path3}" is too large (${size} bytes). Maximum allowed: ${maxSize} bytes`,
413
+ `File "${path4}" is too large (${size} bytes). Maximum allowed: ${maxSize} bytes`,
398
414
  "FILE_TOO_LARGE",
399
- { path: path3, size, maxSize }
415
+ { path: path4, size, maxSize }
400
416
  );
401
417
  this.name = "FileTooLargeError";
402
418
  }
403
419
  };
404
420
  var InvalidExtensionError = class extends HazoFilesError {
405
- constructor(path3, extension, allowedExtensions) {
421
+ constructor(path4, extension, allowedExtensions) {
406
422
  super(
407
423
  `File extension "${extension}" is not allowed. Allowed: ${allowedExtensions.join(", ")}`,
408
424
  "INVALID_EXTENSION",
409
- { path: path3, extension, allowedExtensions }
425
+ { path: path4, extension, allowedExtensions }
410
426
  );
411
427
  this.name = "InvalidExtensionError";
412
428
  }
@@ -429,6 +445,32 @@ var OperationError = class extends HazoFilesError {
429
445
  this.name = "OperationError";
430
446
  }
431
447
  };
448
+ var QuotaExceededError = class extends HazoFilesError {
449
+ constructor(scopeId, byteUsed, byteLimit, deltaBytes) {
450
+ super(
451
+ `Quota exceeded for scope ${scopeId}: would use ${byteUsed + deltaBytes} of ${byteLimit} bytes`,
452
+ "QUOTA_EXCEEDED",
453
+ { scopeId, byteUsed, byteLimit, deltaBytes }
454
+ );
455
+ this.name = "QuotaExceededError";
456
+ }
457
+ };
458
+ var SSRFError = class extends HazoFilesError {
459
+ constructor(url, reason) {
460
+ super(`SSRF check failed for URL "${url}": ${reason}`, "SSRF_ERROR", { url, reason });
461
+ this.name = "SSRFError";
462
+ }
463
+ };
464
+ var ImportSizeCapError = class extends HazoFilesError {
465
+ constructor(url, capBytes) {
466
+ super(
467
+ `Import from "${url}" aborted: response exceeds ${capBytes} byte limit`,
468
+ "IMPORT_SIZE_CAP",
469
+ { url, capBytes }
470
+ );
471
+ this.name = "ImportSizeCapError";
472
+ }
473
+ };
432
474
 
433
475
  // src/common/utils.ts
434
476
  function successResult(data) {
@@ -672,10 +714,10 @@ var BaseStorageModule = class {
672
714
  * Get folder tree structure.
673
715
  * Default implementation that can be overridden by subclasses for optimization.
674
716
  */
675
- async getFolderTree(path3 = "/", depth = 3) {
717
+ async getFolderTree(path4 = "/", depth = 3) {
676
718
  this.ensureInitialized();
677
719
  try {
678
- const result = await this.buildTree(path3, depth, 0);
720
+ const result = await this.buildTree(path4, depth, 0);
679
721
  return successResult(result);
680
722
  } catch (error) {
681
723
  return errorResult(`Failed to get folder tree: ${error.message}`);
@@ -684,11 +726,11 @@ var BaseStorageModule = class {
684
726
  /**
685
727
  * Recursively build folder tree
686
728
  */
687
- async buildTree(path3, maxDepth, currentDepth) {
729
+ async buildTree(path4, maxDepth, currentDepth) {
688
730
  if (currentDepth >= maxDepth) {
689
731
  return [];
690
732
  }
691
- const listResult = await this.listDirectory(path3, { recursive: false });
733
+ const listResult = await this.listDirectory(path4, { recursive: false });
692
734
  if (!listResult.success || !listResult.data) {
693
735
  return [];
694
736
  }
@@ -1507,12 +1549,12 @@ var GoogleDriveModule = class extends BaseStorageModule {
1507
1549
  */
1508
1550
  driveFileToItem(file, virtualPath) {
1509
1551
  const isFolder2 = file.mimeType === FOLDER_MIME_TYPE;
1510
- const path3 = virtualPath || "";
1552
+ const path4 = virtualPath || "";
1511
1553
  if (isFolder2) {
1512
1554
  return createFolderItem({
1513
1555
  id: file.id,
1514
1556
  name: file.name,
1515
- path: path3,
1557
+ path: path4,
1516
1558
  createdAt: file.createdTime ? new Date(file.createdTime) : /* @__PURE__ */ new Date(),
1517
1559
  modifiedAt: file.modifiedTime ? new Date(file.modifiedTime) : /* @__PURE__ */ new Date(),
1518
1560
  metadata: {
@@ -1524,7 +1566,7 @@ var GoogleDriveModule = class extends BaseStorageModule {
1524
1566
  return createFileItem({
1525
1567
  id: file.id,
1526
1568
  name: file.name,
1527
- path: path3,
1569
+ path: path4,
1528
1570
  size: parseInt(file.size || "0", 10),
1529
1571
  mimeType: file.mimeType || "application/octet-stream",
1530
1572
  createdAt: file.createdTime ? new Date(file.createdTime) : /* @__PURE__ */ new Date(),
@@ -1623,10 +1665,10 @@ var GoogleDriveModule = class extends BaseStorageModule {
1623
1665
  }
1624
1666
  let media;
1625
1667
  if (typeof source === "string") {
1626
- const fs3 = await import("fs");
1668
+ const fs4 = await import("fs");
1627
1669
  media = {
1628
1670
  mimeType: "application/octet-stream",
1629
- body: fs3.createReadStream(source)
1671
+ body: fs4.createReadStream(source)
1630
1672
  };
1631
1673
  } else if (Buffer.isBuffer(source)) {
1632
1674
  media = {
@@ -1675,10 +1717,10 @@ var GoogleDriveModule = class extends BaseStorageModule {
1675
1717
  options.onProgress(100, buffer.length, buffer.length);
1676
1718
  }
1677
1719
  if (localPath) {
1678
- const fs3 = await import("fs");
1679
- const path3 = await import("path");
1680
- await fs3.promises.mkdir(path3.dirname(localPath), { recursive: true });
1681
- await fs3.promises.writeFile(localPath, buffer);
1720
+ const fs4 = await import("fs");
1721
+ const path4 = await import("path");
1722
+ await fs4.promises.mkdir(path4.dirname(localPath), { recursive: true });
1723
+ await fs4.promises.writeFile(localPath, buffer);
1682
1724
  return this.successResult(localPath);
1683
1725
  }
1684
1726
  return this.successResult(buffer);
@@ -1866,10 +1908,10 @@ var GoogleDriveModule = class extends BaseStorageModule {
1866
1908
  return false;
1867
1909
  }
1868
1910
  }
1869
- async getFolderTree(path3 = "/", depth = 3) {
1911
+ async getFolderTree(path4 = "/", depth = 3) {
1870
1912
  try {
1871
1913
  await this.ensureAuthenticated();
1872
- return super.getFolderTree(path3, depth);
1914
+ return super.getFolderTree(path4, depth);
1873
1915
  } catch (error) {
1874
1916
  return this.errorResult(`Failed to get folder tree: ${error.message}`);
1875
1917
  }
@@ -2160,12 +2202,12 @@ var DropboxModule = class extends BaseStorageModule {
2160
2202
  */
2161
2203
  metadataToItem(entry, virtualPath) {
2162
2204
  const isFolder2 = entry[".tag"] === "folder";
2163
- const path3 = virtualPath || this.toVirtualPath(entry.path_display || entry.name);
2205
+ const path4 = virtualPath || this.toVirtualPath(entry.path_display || entry.name);
2164
2206
  if (isFolder2) {
2165
2207
  return createFolderItem({
2166
2208
  id: entry.id,
2167
2209
  name: entry.name,
2168
- path: path3,
2210
+ path: path4,
2169
2211
  createdAt: /* @__PURE__ */ new Date(),
2170
2212
  modifiedAt: /* @__PURE__ */ new Date(),
2171
2213
  metadata: {
@@ -2178,7 +2220,7 @@ var DropboxModule = class extends BaseStorageModule {
2178
2220
  return createFileItem({
2179
2221
  id: fileEntry.id,
2180
2222
  name: fileEntry.name,
2181
- path: path3,
2223
+ path: path4,
2182
2224
  size: fileEntry.size,
2183
2225
  mimeType: getMimeType(fileEntry.name),
2184
2226
  createdAt: new Date(fileEntry.client_modified),
@@ -2254,8 +2296,8 @@ var DropboxModule = class extends BaseStorageModule {
2254
2296
  const dbxPath = this.toDropboxPath(remotePath);
2255
2297
  let contents;
2256
2298
  if (typeof source === "string") {
2257
- const fs3 = await import("fs");
2258
- contents = await fs3.promises.readFile(source);
2299
+ const fs4 = await import("fs");
2300
+ contents = await fs4.promises.readFile(source);
2259
2301
  } else if (Buffer.isBuffer(source)) {
2260
2302
  contents = source;
2261
2303
  } else {
@@ -2324,10 +2366,10 @@ var DropboxModule = class extends BaseStorageModule {
2324
2366
  options.onProgress(100, buffer.length, buffer.length);
2325
2367
  }
2326
2368
  if (localPath) {
2327
- const fs3 = await import("fs");
2328
- const path3 = await import("path");
2329
- await fs3.promises.mkdir(path3.dirname(localPath), { recursive: true });
2330
- await fs3.promises.writeFile(localPath, buffer);
2369
+ const fs4 = await import("fs");
2370
+ const path4 = await import("path");
2371
+ await fs4.promises.mkdir(path4.dirname(localPath), { recursive: true });
2372
+ await fs4.promises.writeFile(localPath, buffer);
2331
2373
  return this.successResult(localPath);
2332
2374
  }
2333
2375
  return this.successResult(buffer);
@@ -2496,10 +2538,10 @@ var DropboxModule = class extends BaseStorageModule {
2496
2538
  return false;
2497
2539
  }
2498
2540
  }
2499
- async getFolderTree(path3 = "/", depth = 3) {
2541
+ async getFolderTree(path4 = "/", depth = 3) {
2500
2542
  try {
2501
2543
  await this.ensureAuthenticated();
2502
- return super.getFolderTree(path3, depth);
2544
+ return super.getFolderTree(path4, depth);
2503
2545
  } catch (error) {
2504
2546
  return this.errorResult(`Failed to get folder tree: ${error.message}`);
2505
2547
  }
@@ -2618,13 +2660,13 @@ var FileManager = class {
2618
2660
  /**
2619
2661
  * Create a directory at the specified path
2620
2662
  */
2621
- async createDirectory(path3) {
2663
+ async createDirectory(path4) {
2622
2664
  this.ensureInitialized();
2623
2665
  const start = Date.now();
2624
- const result = await this.module.createDirectory(path3);
2666
+ const result = await this.module.createDirectory(path4);
2625
2667
  this.logFileOp(result.success ? "info" : "error", {
2626
2668
  operation: "upload",
2627
- file_path: path3,
2669
+ file_path: path4,
2628
2670
  mime_type: "folder",
2629
2671
  storage: this.config?.provider,
2630
2672
  duration_ms: Date.now() - start,
@@ -2639,13 +2681,13 @@ var FileManager = class {
2639
2681
  * @param path - Directory path
2640
2682
  * @param recursive - If true, remove directory and all contents
2641
2683
  */
2642
- async removeDirectory(path3, recursive = false) {
2684
+ async removeDirectory(path4, recursive = false) {
2643
2685
  this.ensureInitialized();
2644
2686
  const start = Date.now();
2645
- const result = await this.module.removeDirectory(path3, recursive);
2687
+ const result = await this.module.removeDirectory(path4, recursive);
2646
2688
  this.logFileOp(result.success ? "info" : "error", {
2647
2689
  operation: "delete",
2648
- file_path: path3,
2690
+ file_path: path4,
2649
2691
  mime_type: "folder",
2650
2692
  storage: this.config?.provider,
2651
2693
  duration_ms: Date.now() - start,
@@ -2724,13 +2766,13 @@ var FileManager = class {
2724
2766
  /**
2725
2767
  * Delete a file
2726
2768
  */
2727
- async deleteFile(path3) {
2769
+ async deleteFile(path4) {
2728
2770
  this.ensureInitialized();
2729
2771
  const start = Date.now();
2730
- const result = await this.module.deleteFile(path3);
2772
+ const result = await this.module.deleteFile(path4);
2731
2773
  this.logFileOp(result.success ? "info" : "error", {
2732
2774
  operation: "delete",
2733
- file_path: path3,
2775
+ file_path: path4,
2734
2776
  storage: this.config?.provider,
2735
2777
  duration_ms: Date.now() - start,
2736
2778
  success: result.success,
@@ -2744,14 +2786,14 @@ var FileManager = class {
2744
2786
  * @param newName - New filename (not full path)
2745
2787
  * @param options - Rename options
2746
2788
  */
2747
- async renameFile(path3, newName, options) {
2789
+ async renameFile(path4, newName, options) {
2748
2790
  this.ensureInitialized();
2749
2791
  const start = Date.now();
2750
- const result = await this.module.renameFile(path3, newName, options);
2792
+ const result = await this.module.renameFile(path4, newName, options);
2751
2793
  this.logFileOp(result.success ? "info" : "error", {
2752
2794
  operation: "move",
2753
2795
  file_name: result.data?.name,
2754
- file_path: path3,
2796
+ file_path: path4,
2755
2797
  storage: this.config?.provider,
2756
2798
  duration_ms: Date.now() - start,
2757
2799
  success: result.success,
@@ -2766,14 +2808,14 @@ var FileManager = class {
2766
2808
  * @param newName - New folder name (not full path)
2767
2809
  * @param options - Rename options
2768
2810
  */
2769
- async renameFolder(path3, newName, options) {
2811
+ async renameFolder(path4, newName, options) {
2770
2812
  this.ensureInitialized();
2771
2813
  const start = Date.now();
2772
- const result = await this.module.renameFolder(path3, newName, options);
2814
+ const result = await this.module.renameFolder(path4, newName, options);
2773
2815
  this.logFileOp(result.success ? "info" : "error", {
2774
2816
  operation: "move",
2775
2817
  file_name: result.data?.name,
2776
- file_path: path3,
2818
+ file_path: path4,
2777
2819
  storage: this.config?.provider,
2778
2820
  duration_ms: Date.now() - start,
2779
2821
  success: result.success,
@@ -2788,54 +2830,54 @@ var FileManager = class {
2788
2830
  * @param path - Directory path
2789
2831
  * @param options - List options
2790
2832
  */
2791
- async listDirectory(path3, options) {
2833
+ async listDirectory(path4, options) {
2792
2834
  this.ensureInitialized();
2793
- return this.module.listDirectory(path3, options);
2835
+ return this.module.listDirectory(path4, options);
2794
2836
  }
2795
2837
  /**
2796
2838
  * Get information about a file or folder
2797
2839
  */
2798
- async getItem(path3) {
2840
+ async getItem(path4) {
2799
2841
  this.ensureInitialized();
2800
- return this.module.getItem(path3);
2842
+ return this.module.getItem(path4);
2801
2843
  }
2802
2844
  /**
2803
2845
  * Check if a file or folder exists
2804
2846
  */
2805
- async exists(path3) {
2847
+ async exists(path4) {
2806
2848
  this.ensureInitialized();
2807
- return this.module.exists(path3);
2849
+ return this.module.exists(path4);
2808
2850
  }
2809
2851
  /**
2810
2852
  * Get folder tree structure
2811
2853
  * @param path - Starting path (default: root)
2812
2854
  * @param depth - Maximum depth to traverse
2813
2855
  */
2814
- async getFolderTree(path3 = "/", depth = 3) {
2856
+ async getFolderTree(path4 = "/", depth = 3) {
2815
2857
  this.ensureInitialized();
2816
- return this.module.getFolderTree(path3, depth);
2858
+ return this.module.getFolderTree(path4, depth);
2817
2859
  }
2818
2860
  // ============ Convenience Methods ============
2819
2861
  /**
2820
2862
  * Create a file with string content
2821
2863
  */
2822
- async writeFile(path3, content, options) {
2864
+ async writeFile(path4, content, options) {
2823
2865
  const buffer = Buffer.from(content, "utf-8");
2824
- return this.uploadFile(buffer, path3, options);
2866
+ return this.uploadFile(buffer, path4, options);
2825
2867
  }
2826
2868
  /**
2827
2869
  * Read a file as string
2828
2870
  */
2829
- async readFile(path3) {
2830
- const result = await this.downloadFile(path3);
2871
+ async readFile(path4) {
2872
+ const result = await this.downloadFile(path4);
2831
2873
  if (!result.success) {
2832
2874
  return { success: false, error: result.error };
2833
2875
  }
2834
2876
  if (Buffer.isBuffer(result.data)) {
2835
2877
  return { success: true, data: result.data.toString("utf-8") };
2836
2878
  }
2837
- const fs3 = await import("fs");
2838
- const content = await fs3.promises.readFile(result.data, "utf-8");
2879
+ const fs4 = await import("fs");
2880
+ const content = await fs4.promises.readFile(result.data, "utf-8");
2839
2881
  return { success: true, data: content };
2840
2882
  }
2841
2883
  /**
@@ -2846,22 +2888,22 @@ var FileManager = class {
2846
2888
  if (!downloadResult.success) {
2847
2889
  return { success: false, error: downloadResult.error };
2848
2890
  }
2849
- const buffer = Buffer.isBuffer(downloadResult.data) ? downloadResult.data : await import("fs").then((fs3) => fs3.promises.readFile(downloadResult.data));
2891
+ const buffer = Buffer.isBuffer(downloadResult.data) ? downloadResult.data : await import("fs").then((fs4) => fs4.promises.readFile(downloadResult.data));
2850
2892
  return this.uploadFile(buffer, destinationPath, options);
2851
2893
  }
2852
2894
  /**
2853
2895
  * Ensure a directory exists (creates if needed)
2854
2896
  */
2855
- async ensureDirectory(path3) {
2856
- const exists = await this.exists(path3);
2897
+ async ensureDirectory(path4) {
2898
+ const exists = await this.exists(path4);
2857
2899
  if (exists) {
2858
- const item = await this.getItem(path3);
2900
+ const item = await this.getItem(path4);
2859
2901
  if (item.success && item.data?.isDirectory) {
2860
2902
  return { success: true, data: item.data };
2861
2903
  }
2862
2904
  return { success: false, error: "Path exists but is not a directory" };
2863
2905
  }
2864
- return this.createDirectory(path3);
2906
+ return this.createDirectory(path4);
2865
2907
  }
2866
2908
  };
2867
2909
  function createFileManager(options) {
@@ -3174,6 +3216,8 @@ var FileMetadataService = class {
3174
3216
  if (input.uploaded_by !== void 0) record.uploaded_by = input.uploaded_by;
3175
3217
  if (input.original_filename !== void 0) record.original_filename = input.original_filename;
3176
3218
  if (input.content_tag !== void 0) record.content_tag = input.content_tag;
3219
+ if (input.changed_by !== void 0) record.changed_by = input.changed_by;
3220
+ if (input.source_url !== void 0) record.source_url = input.source_url;
3177
3221
  const results = await this.crud.insert(record);
3178
3222
  const duration_ms = Date.now() - start;
3179
3223
  this.logger?.debug?.("Recorded file upload", { path: input.file_path });
@@ -3206,32 +3250,32 @@ var FileMetadataService = class {
3206
3250
  /**
3207
3251
  * Record a directory creation
3208
3252
  */
3209
- async recordDirectoryCreation(path3, storageType, metadata) {
3253
+ async recordDirectoryCreation(path4, storageType, metadata) {
3210
3254
  return this.recordUpload({
3211
- filename: getBaseName(path3),
3255
+ filename: getBaseName(path4),
3212
3256
  file_type: "folder",
3213
3257
  file_data: metadata,
3214
- file_path: path3,
3258
+ file_path: path4,
3215
3259
  storage_type: storageType
3216
3260
  });
3217
3261
  }
3218
3262
  /**
3219
3263
  * Record a file access (download)
3220
3264
  */
3221
- async recordAccess(path3, storageType) {
3265
+ async recordAccess(path4, storageType) {
3222
3266
  const start = Date.now();
3223
3267
  try {
3224
- const existing = await this.findByPath(path3, storageType);
3268
+ const existing = await this.findByPath(path4, storageType);
3225
3269
  if (existing) {
3226
3270
  await this.crud.updateById(existing.id, {
3227
3271
  changed_at: this.now()
3228
3272
  });
3229
3273
  const duration_ms = Date.now() - start;
3230
- this.logger?.debug?.("Recorded file access", { path: path3 });
3274
+ this.logger?.debug?.("Recorded file access", { path: path4 });
3231
3275
  this.logger?.info?.("file_operation", {
3232
3276
  operation: "download",
3233
3277
  file_name: existing.filename,
3234
- file_path: path3,
3278
+ file_path: path4,
3235
3279
  mime_type: existing.file_type,
3236
3280
  size_bytes: existing.file_size,
3237
3281
  storage: storageType,
@@ -3245,7 +3289,7 @@ var FileMetadataService = class {
3245
3289
  const duration_ms = Date.now() - start;
3246
3290
  this.logger?.error?.("file_operation", {
3247
3291
  operation: "download",
3248
- file_path: path3,
3292
+ file_path: path4,
3249
3293
  storage: storageType,
3250
3294
  duration_ms,
3251
3295
  success: false,
@@ -3257,19 +3301,20 @@ var FileMetadataService = class {
3257
3301
  }
3258
3302
  /**
3259
3303
  * Record a file deletion
3304
+ * changedBy is accepted for API consistency but not written (record is deleted)
3260
3305
  */
3261
- async recordDelete(path3, storageType) {
3306
+ async recordDelete(path4, storageType, _changedBy) {
3262
3307
  const start = Date.now();
3263
3308
  try {
3264
- const existing = await this.findByPath(path3, storageType);
3309
+ const existing = await this.findByPath(path4, storageType);
3265
3310
  if (existing) {
3266
3311
  await this.crud.deleteById(existing.id);
3267
3312
  const duration_ms = Date.now() - start;
3268
- this.logger?.debug?.("Recorded file deletion", { path: path3 });
3313
+ this.logger?.debug?.("Recorded file deletion", { path: path4 });
3269
3314
  this.logger?.info?.("file_operation", {
3270
3315
  operation: "delete",
3271
3316
  file_name: existing.filename,
3272
- file_path: path3,
3317
+ file_path: path4,
3273
3318
  mime_type: existing.file_type,
3274
3319
  size_bytes: existing.file_size,
3275
3320
  storage: storageType,
@@ -3283,7 +3328,7 @@ var FileMetadataService = class {
3283
3328
  const duration_ms = Date.now() - start;
3284
3329
  this.logger?.error?.("file_operation", {
3285
3330
  operation: "delete",
3286
- file_path: path3,
3331
+ file_path: path4,
3287
3332
  storage: storageType,
3288
3333
  duration_ms,
3289
3334
  success: false,
@@ -3296,23 +3341,23 @@ var FileMetadataService = class {
3296
3341
  /**
3297
3342
  * Record a directory deletion (recursive)
3298
3343
  */
3299
- async recordDirectoryDelete(path3, storageType, recursive) {
3344
+ async recordDirectoryDelete(path4, storageType, recursive) {
3300
3345
  try {
3301
3346
  if (recursive) {
3302
3347
  const records = await this.crud.findBy({ storage_type: storageType });
3303
3348
  const toDelete = records.filter(
3304
- (r) => r.file_path === path3 || r.file_path.startsWith(path3 + "/")
3349
+ (r) => r.file_path === path4 || r.file_path.startsWith(path4 + "/")
3305
3350
  );
3306
3351
  for (const record of toDelete) {
3307
3352
  await this.crud.deleteById(record.id);
3308
3353
  }
3309
3354
  this.logger?.debug?.("Recorded recursive directory deletion", {
3310
- path: path3,
3355
+ path: path4,
3311
3356
  count: toDelete.length
3312
3357
  });
3313
3358
  return true;
3314
3359
  } else {
3315
- return this.recordDelete(path3, storageType);
3360
+ return this.recordDelete(path4, storageType);
3316
3361
  }
3317
3362
  } catch (error) {
3318
3363
  this.logError("recordDirectoryDelete", error);
@@ -3322,16 +3367,18 @@ var FileMetadataService = class {
3322
3367
  /**
3323
3368
  * Record a file or folder move
3324
3369
  */
3325
- async recordMove(sourcePath, destinationPath, storageType) {
3370
+ async recordMove(sourcePath, destinationPath, storageType, changedBy) {
3326
3371
  const start = Date.now();
3327
3372
  try {
3328
3373
  const existing = await this.findByPath(sourcePath, storageType);
3329
3374
  if (existing) {
3330
- await this.crud.updateById(existing.id, {
3375
+ const patch = {
3331
3376
  file_path: destinationPath,
3332
3377
  filename: getBaseName(destinationPath),
3333
3378
  changed_at: this.now()
3334
- });
3379
+ };
3380
+ if (changedBy !== void 0) patch.changed_by = changedBy;
3381
+ await this.crud.updateById(existing.id, patch);
3335
3382
  const duration_ms = Date.now() - start;
3336
3383
  this.logger?.debug?.("Recorded file move", { from: sourcePath, to: destinationPath });
3337
3384
  this.logger?.info?.("file_operation", {
@@ -3365,24 +3412,26 @@ var FileMetadataService = class {
3365
3412
  /**
3366
3413
  * Record a file or folder rename
3367
3414
  */
3368
- async recordRename(path3, newName, storageType) {
3415
+ async recordRename(path4, newName, storageType, changedBy) {
3369
3416
  const start = Date.now();
3370
3417
  try {
3371
- const existing = await this.findByPath(path3, storageType);
3418
+ const existing = await this.findByPath(path4, storageType);
3372
3419
  if (existing) {
3373
- const parentPath = getDirName(path3);
3420
+ const parentPath = getDirName(path4);
3374
3421
  const newPath = parentPath === "/" ? `/${newName}` : `${parentPath}/${newName}`;
3375
- await this.crud.updateById(existing.id, {
3422
+ const patch = {
3376
3423
  filename: newName,
3377
3424
  file_path: newPath,
3378
3425
  changed_at: this.now()
3379
- });
3426
+ };
3427
+ if (changedBy !== void 0) patch.changed_by = changedBy;
3428
+ await this.crud.updateById(existing.id, patch);
3380
3429
  const duration_ms = Date.now() - start;
3381
- this.logger?.debug?.("Recorded file rename", { path: path3, newName });
3430
+ this.logger?.debug?.("Recorded file rename", { path: path4, newName });
3382
3431
  this.logger?.info?.("file_operation", {
3383
3432
  operation: "move",
3384
3433
  file_name: existing.filename,
3385
- file_path: path3,
3434
+ file_path: path4,
3386
3435
  mime_type: existing.file_type,
3387
3436
  size_bytes: existing.file_size,
3388
3437
  storage: storageType,
@@ -3397,7 +3446,7 @@ var FileMetadataService = class {
3397
3446
  const duration_ms = Date.now() - start;
3398
3447
  this.logger?.error?.("file_operation", {
3399
3448
  operation: "move",
3400
- file_path: path3,
3449
+ file_path: path4,
3401
3450
  storage: storageType,
3402
3451
  duration_ms,
3403
3452
  success: false,
@@ -3411,10 +3460,10 @@ var FileMetadataService = class {
3411
3460
  /**
3412
3461
  * Find a record by path and storage type
3413
3462
  */
3414
- async findByPath(path3, storageType) {
3463
+ async findByPath(path4, storageType) {
3415
3464
  try {
3416
3465
  return await this.crud.findOneBy({
3417
- file_path: path3,
3466
+ file_path: path4,
3418
3467
  storage_type: storageType
3419
3468
  });
3420
3469
  } catch (error) {
@@ -3457,9 +3506,9 @@ var FileMetadataService = class {
3457
3506
  /**
3458
3507
  * Update custom metadata for a file
3459
3508
  */
3460
- async updateMetadata(path3, storageType, metadata) {
3509
+ async updateMetadata(path4, storageType, metadata) {
3461
3510
  try {
3462
- const existing = await this.findByPath(path3, storageType);
3511
+ const existing = await this.findByPath(path4, storageType);
3463
3512
  if (existing) {
3464
3513
  const currentData = JSON.parse(existing.file_data || "{}");
3465
3514
  const newData = { ...currentData, ...metadata };
@@ -3467,7 +3516,7 @@ var FileMetadataService = class {
3467
3516
  file_data: JSON.stringify(newData),
3468
3517
  changed_at: this.now()
3469
3518
  });
3470
- this.logger?.debug?.("Updated file metadata", { path: path3 });
3519
+ this.logger?.debug?.("Updated file metadata", { path: path4 });
3471
3520
  return true;
3472
3521
  }
3473
3522
  return false;
@@ -3483,9 +3532,9 @@ var FileMetadataService = class {
3483
3532
  * Get parsed file_data structure for a file
3484
3533
  * Automatically migrates old format to new extraction structure
3485
3534
  */
3486
- async getFileData(path3, storageType) {
3535
+ async getFileData(path4, storageType) {
3487
3536
  try {
3488
- const existing = await this.findByPath(path3, storageType);
3537
+ const existing = await this.findByPath(path4, storageType);
3489
3538
  if (!existing) {
3490
3539
  return null;
3491
3540
  }
@@ -3498,9 +3547,9 @@ var FileMetadataService = class {
3498
3547
  /**
3499
3548
  * Get merged extraction data for a file
3500
3549
  */
3501
- async getMergedData(path3, storageType) {
3550
+ async getMergedData(path4, storageType) {
3502
3551
  try {
3503
- const fileData = await this.getFileData(path3, storageType);
3552
+ const fileData = await this.getFileData(path4, storageType);
3504
3553
  if (!fileData) {
3505
3554
  return null;
3506
3555
  }
@@ -3513,12 +3562,12 @@ var FileMetadataService = class {
3513
3562
  /**
3514
3563
  * Add an extraction to a file's data
3515
3564
  */
3516
- async addExtraction(path3, storageType, data, options) {
3565
+ async addExtraction(path4, storageType, data, options) {
3517
3566
  const start = Date.now();
3518
3567
  try {
3519
- const existing = await this.findByPath(path3, storageType);
3568
+ const existing = await this.findByPath(path4, storageType);
3520
3569
  if (!existing) {
3521
- this.logger?.warn?.("Cannot add extraction: file not found", { path: path3 });
3570
+ this.logger?.warn?.("Cannot add extraction: file not found", { path: path4 });
3522
3571
  return null;
3523
3572
  }
3524
3573
  const currentFileData = parseFileData(existing.file_data);
@@ -3533,11 +3582,11 @@ var FileMetadataService = class {
3533
3582
  });
3534
3583
  const newExtraction = result.data.raw_data[result.data.raw_data.length - 1];
3535
3584
  const duration_ms = Date.now() - start;
3536
- this.logger?.debug?.("Added extraction", { path: path3, extractionId: newExtraction.id });
3585
+ this.logger?.debug?.("Added extraction", { path: path4, extractionId: newExtraction.id });
3537
3586
  this.logger?.info?.("file_operation", {
3538
3587
  operation: "extract",
3539
3588
  file_name: existing.filename,
3540
- file_path: path3,
3589
+ file_path: path4,
3541
3590
  mime_type: existing.file_type,
3542
3591
  storage: storageType,
3543
3592
  duration_ms,
@@ -3549,7 +3598,7 @@ var FileMetadataService = class {
3549
3598
  const duration_ms = Date.now() - start;
3550
3599
  this.logger?.error?.("file_operation", {
3551
3600
  operation: "extract",
3552
- file_path: path3,
3601
+ file_path: path4,
3553
3602
  storage: storageType,
3554
3603
  duration_ms,
3555
3604
  success: false,
@@ -3562,9 +3611,9 @@ var FileMetadataService = class {
3562
3611
  /**
3563
3612
  * Remove an extraction by ID
3564
3613
  */
3565
- async removeExtractionById(path3, storageType, id, options) {
3614
+ async removeExtractionById(path4, storageType, id, options) {
3566
3615
  try {
3567
- const existing = await this.findByPath(path3, storageType);
3616
+ const existing = await this.findByPath(path4, storageType);
3568
3617
  if (!existing) {
3569
3618
  return false;
3570
3619
  }
@@ -3578,7 +3627,7 @@ var FileMetadataService = class {
3578
3627
  file_data: stringifyFileData(result.data),
3579
3628
  changed_at: this.now()
3580
3629
  });
3581
- this.logger?.debug?.("Removed extraction by ID", { path: path3, extractionId: id });
3630
+ this.logger?.debug?.("Removed extraction by ID", { path: path4, extractionId: id });
3582
3631
  return true;
3583
3632
  } catch (error) {
3584
3633
  this.logError("removeExtractionById", error);
@@ -3588,9 +3637,9 @@ var FileMetadataService = class {
3588
3637
  /**
3589
3638
  * Remove an extraction by index
3590
3639
  */
3591
- async removeExtractionByIndex(path3, storageType, index, options) {
3640
+ async removeExtractionByIndex(path4, storageType, index, options) {
3592
3641
  try {
3593
- const existing = await this.findByPath(path3, storageType);
3642
+ const existing = await this.findByPath(path4, storageType);
3594
3643
  if (!existing) {
3595
3644
  return false;
3596
3645
  }
@@ -3604,7 +3653,7 @@ var FileMetadataService = class {
3604
3653
  file_data: stringifyFileData(result.data),
3605
3654
  changed_at: this.now()
3606
3655
  });
3607
- this.logger?.debug?.("Removed extraction by index", { path: path3, index });
3656
+ this.logger?.debug?.("Removed extraction by index", { path: path4, index });
3608
3657
  return true;
3609
3658
  } catch (error) {
3610
3659
  this.logError("removeExtractionByIndex", error);
@@ -3614,9 +3663,9 @@ var FileMetadataService = class {
3614
3663
  /**
3615
3664
  * Get all extractions for a file
3616
3665
  */
3617
- async getExtractions(path3, storageType) {
3666
+ async getExtractions(path4, storageType) {
3618
3667
  try {
3619
- const fileData = await this.getFileData(path3, storageType);
3668
+ const fileData = await this.getFileData(path4, storageType);
3620
3669
  if (!fileData) {
3621
3670
  return null;
3622
3671
  }
@@ -3629,9 +3678,9 @@ var FileMetadataService = class {
3629
3678
  /**
3630
3679
  * Get a specific extraction by ID
3631
3680
  */
3632
- async getExtractionById(path3, storageType, id) {
3681
+ async getExtractionById(path4, storageType, id) {
3633
3682
  try {
3634
- const fileData = await this.getFileData(path3, storageType);
3683
+ const fileData = await this.getFileData(path4, storageType);
3635
3684
  if (!fileData) {
3636
3685
  return null;
3637
3686
  }
@@ -3644,9 +3693,9 @@ var FileMetadataService = class {
3644
3693
  /**
3645
3694
  * Clear all extractions for a file
3646
3695
  */
3647
- async clearExtractions(path3, storageType) {
3696
+ async clearExtractions(path4, storageType) {
3648
3697
  try {
3649
- const existing = await this.findByPath(path3, storageType);
3698
+ const existing = await this.findByPath(path4, storageType);
3650
3699
  if (!existing) {
3651
3700
  return false;
3652
3701
  }
@@ -3654,7 +3703,7 @@ var FileMetadataService = class {
3654
3703
  file_data: stringifyFileData(createEmptyFileDataStructure()),
3655
3704
  changed_at: this.now()
3656
3705
  });
3657
- this.logger?.debug?.("Cleared all extractions", { path: path3 });
3706
+ this.logger?.debug?.("Cleared all extractions", { path: path4 });
3658
3707
  return true;
3659
3708
  } catch (error) {
3660
3709
  this.logError("clearExtractions", error);
@@ -3864,7 +3913,7 @@ var FileMetadataService = class {
3864
3913
  return this.updateStatus(fileId, "soft_deleted");
3865
3914
  }
3866
3915
  /**
3867
- * Update specific V2 fields on a record
3916
+ * Update specific V2/V4 fields on a record
3868
3917
  */
3869
3918
  async updateFields(fileId, fields) {
3870
3919
  try {
@@ -3995,6 +4044,7 @@ var TrackedFileManager = class extends FileManager {
3995
4044
  constructor(options = {}) {
3996
4045
  super(options);
3997
4046
  this.metadataService = null;
4047
+ this.quotaService = null;
3998
4048
  this.trackingConfig = {
3999
4049
  enabled: options.tracking?.enabled ?? false,
4000
4050
  tableName: options.tracking?.tableName ?? "hazo_files",
@@ -4008,6 +4058,8 @@ var TrackedFileManager = class extends FileManager {
4008
4058
  logErrors: this.trackingConfig.logErrors
4009
4059
  });
4010
4060
  }
4061
+ this.quotaService = options.quotaService ?? null;
4062
+ this.ssrfAllowlist = options.ssrfAllowlist ?? [];
4011
4063
  }
4012
4064
  /**
4013
4065
  * Check if tracking is enabled and service is available
@@ -4025,11 +4077,11 @@ var TrackedFileManager = class extends FileManager {
4025
4077
  /**
4026
4078
  * Create a directory and record it in the database
4027
4079
  */
4028
- async createDirectory(path3) {
4029
- const result = await super.createDirectory(path3);
4080
+ async createDirectory(path4) {
4081
+ const result = await super.createDirectory(path4);
4030
4082
  if (result.success && this.isTrackingEnabled()) {
4031
4083
  this.metadataService.recordDirectoryCreation(
4032
- path3,
4084
+ path4,
4033
4085
  this.getStorageType(),
4034
4086
  result.data?.metadata
4035
4087
  ).catch(() => {
@@ -4040,11 +4092,11 @@ var TrackedFileManager = class extends FileManager {
4040
4092
  /**
4041
4093
  * Remove a directory and delete its record from the database
4042
4094
  */
4043
- async removeDirectory(path3, recursive = false) {
4044
- const result = await super.removeDirectory(path3, recursive);
4095
+ async removeDirectory(path4, recursive = false) {
4096
+ const result = await super.removeDirectory(path4, recursive);
4045
4097
  if (result.success && this.isTrackingEnabled()) {
4046
4098
  this.metadataService.recordDirectoryDelete(
4047
- path3,
4099
+ path4,
4048
4100
  this.getStorageType(),
4049
4101
  recursive
4050
4102
  ).catch(() => {
@@ -4062,7 +4114,16 @@ var TrackedFileManager = class extends FileManager {
4062
4114
  if (source instanceof Buffer) {
4063
4115
  fileBuffer = source;
4064
4116
  }
4117
+ const scope_id = options?.scope_id;
4118
+ const preCheckSize = fileBuffer?.length ?? 0;
4119
+ if (this.quotaService && scope_id && preCheckSize > 0) {
4120
+ await this.quotaService.checkQuota(scope_id, preCheckSize);
4121
+ }
4065
4122
  const result = await super.uploadFile(source, remotePath, options);
4123
+ if (result.success && this.quotaService && scope_id && preCheckSize > 0) {
4124
+ await this.quotaService.incrementUsage(scope_id, preCheckSize).catch(() => {
4125
+ });
4126
+ }
4066
4127
  if (result.success && this.isTrackingEnabled() && result.data) {
4067
4128
  const fileItem = result.data;
4068
4129
  const skipHash = options?.skipHash ?? false;
@@ -4086,7 +4147,10 @@ var TrackedFileManager = class extends FileManager {
4086
4147
  file_path: remotePath,
4087
4148
  storage_type: this.getStorageType(),
4088
4149
  file_hash: fileHash,
4089
- file_size: fileSize
4150
+ file_size: fileSize,
4151
+ uploaded_by: options?.actor_id,
4152
+ changed_by: options?.actor_id,
4153
+ scope_id: options?.scope_id
4090
4154
  });
4091
4155
  if (awaitRecording) {
4092
4156
  await recordPromise;
@@ -4120,7 +4184,8 @@ var TrackedFileManager = class extends FileManager {
4120
4184
  this.metadataService.recordMove(
4121
4185
  sourcePath,
4122
4186
  destinationPath,
4123
- this.getStorageType()
4187
+ this.getStorageType(),
4188
+ options?.actor_id
4124
4189
  ).catch(() => {
4125
4190
  });
4126
4191
  }
@@ -4129,12 +4194,13 @@ var TrackedFileManager = class extends FileManager {
4129
4194
  /**
4130
4195
  * Delete a file and remove its record from the database
4131
4196
  */
4132
- async deleteFile(path3) {
4133
- const result = await super.deleteFile(path3);
4197
+ async deleteFile(path4, opts) {
4198
+ const result = await super.deleteFile(path4);
4134
4199
  if (result.success && this.isTrackingEnabled()) {
4135
4200
  this.metadataService.recordDelete(
4136
- path3,
4137
- this.getStorageType()
4201
+ path4,
4202
+ this.getStorageType(),
4203
+ opts?.actor_id
4138
4204
  ).catch(() => {
4139
4205
  });
4140
4206
  }
@@ -4143,13 +4209,14 @@ var TrackedFileManager = class extends FileManager {
4143
4209
  /**
4144
4210
  * Rename a file and update its record in the database
4145
4211
  */
4146
- async renameFile(path3, newName, options) {
4147
- const result = await super.renameFile(path3, newName, options);
4212
+ async renameFile(path4, newName, options) {
4213
+ const result = await super.renameFile(path4, newName, options);
4148
4214
  if (result.success && this.isTrackingEnabled()) {
4149
4215
  this.metadataService.recordRename(
4150
- path3,
4216
+ path4,
4151
4217
  newName,
4152
- this.getStorageType()
4218
+ this.getStorageType(),
4219
+ options?.actor_id
4153
4220
  ).catch(() => {
4154
4221
  });
4155
4222
  }
@@ -4158,13 +4225,14 @@ var TrackedFileManager = class extends FileManager {
4158
4225
  /**
4159
4226
  * Rename a folder and update its record in the database
4160
4227
  */
4161
- async renameFolder(path3, newName, options) {
4162
- const result = await super.renameFolder(path3, newName, options);
4228
+ async renameFolder(path4, newName, options) {
4229
+ const result = await super.renameFolder(path4, newName, options);
4163
4230
  if (result.success && this.isTrackingEnabled()) {
4164
4231
  this.metadataService.recordRename(
4165
- path3,
4232
+ path4,
4166
4233
  newName,
4167
- this.getStorageType()
4234
+ this.getStorageType(),
4235
+ options?.actor_id
4168
4236
  ).catch(() => {
4169
4237
  });
4170
4238
  }
@@ -4174,15 +4242,15 @@ var TrackedFileManager = class extends FileManager {
4174
4242
  /**
4175
4243
  * Write a file with string content and track it
4176
4244
  */
4177
- async writeFile(path3, content, options) {
4245
+ async writeFile(path4, content, options) {
4178
4246
  const buffer = Buffer.from(content, "utf-8");
4179
- return this.uploadFile(buffer, path3, options);
4247
+ return this.uploadFile(buffer, path4, options);
4180
4248
  }
4181
4249
  /**
4182
4250
  * Read a file and optionally track access
4183
4251
  */
4184
- async readFile(path3) {
4185
- return super.readFile(path3);
4252
+ async readFile(path4) {
4253
+ return super.readFile(path4);
4186
4254
  }
4187
4255
  /**
4188
4256
  * Copy a file and track the new file
@@ -4227,11 +4295,11 @@ var TrackedFileManager = class extends FileManager {
4227
4295
  * }
4228
4296
  * ```
4229
4297
  */
4230
- async hasFileChanged(path3) {
4298
+ async hasFileChanged(path4) {
4231
4299
  if (!this.isTrackingEnabled()) {
4232
4300
  return null;
4233
4301
  }
4234
- const record = await this.metadataService.findByPath(path3, this.getStorageType());
4302
+ const record = await this.metadataService.findByPath(path4, this.getStorageType());
4235
4303
  if (!record) {
4236
4304
  return null;
4237
4305
  }
@@ -4239,7 +4307,7 @@ var TrackedFileManager = class extends FileManager {
4239
4307
  if (!storedHash) {
4240
4308
  return true;
4241
4309
  }
4242
- const downloadResult = await super.downloadFile(path3);
4310
+ const downloadResult = await super.downloadFile(path4);
4243
4311
  if (!downloadResult.success || !downloadResult.data) {
4244
4312
  return null;
4245
4313
  }
@@ -4259,11 +4327,11 @@ var TrackedFileManager = class extends FileManager {
4259
4327
  * @param path - Virtual path to the file
4260
4328
  * @returns Stored hash or null if not found/not tracked
4261
4329
  */
4262
- async getStoredHash(path3) {
4330
+ async getStoredHash(path4) {
4263
4331
  if (!this.isTrackingEnabled()) {
4264
4332
  return null;
4265
4333
  }
4266
- const record = await this.metadataService.findByPath(path3, this.getStorageType());
4334
+ const record = await this.metadataService.findByPath(path4, this.getStorageType());
4267
4335
  return record?.file_hash || null;
4268
4336
  }
4269
4337
  /**
@@ -4272,11 +4340,11 @@ var TrackedFileManager = class extends FileManager {
4272
4340
  * @param path - Virtual path to the file
4273
4341
  * @returns Stored size in bytes or null if not found/not tracked
4274
4342
  */
4275
- async getStoredSize(path3) {
4343
+ async getStoredSize(path4) {
4276
4344
  if (!this.isTrackingEnabled()) {
4277
4345
  return null;
4278
4346
  }
4279
- const record = await this.metadataService.findByPath(path3, this.getStorageType());
4347
+ const record = await this.metadataService.findByPath(path4, this.getStorageType());
4280
4348
  return record?.file_size ?? null;
4281
4349
  }
4282
4350
  // ============ Reference Tracking Methods (V2) ============
@@ -4310,10 +4378,67 @@ var TrackedFileManager = class extends FileManager {
4310
4378
  }
4311
4379
  /**
4312
4380
  * Soft-delete a file (marks as soft_deleted, does not remove physical file)
4381
+ * Also decrements quota usage if quotaService is configured.
4313
4382
  */
4314
- async softDeleteFile(fileId) {
4383
+ async softDeleteFile(fileId, opts) {
4315
4384
  if (!this.isTrackingEnabled()) return false;
4316
- return this.metadataService.softDelete(fileId);
4385
+ let fileSizeForQuota;
4386
+ let scopeIdForQuota;
4387
+ if (this.quotaService) {
4388
+ const record = await this.metadataService.findById(fileId);
4389
+ if (record) {
4390
+ fileSizeForQuota = record.file_size ?? void 0;
4391
+ scopeIdForQuota = record.scope_id;
4392
+ }
4393
+ }
4394
+ const ok = await this.metadataService.softDelete(fileId);
4395
+ if (ok) {
4396
+ if (opts?.actor_id) {
4397
+ await this.metadataService.updateFields(fileId, { changed_by: opts.actor_id });
4398
+ }
4399
+ if (this.quotaService && scopeIdForQuota && fileSizeForQuota !== void 0) {
4400
+ await this.quotaService.decrementUsage(scopeIdForQuota, fileSizeForQuota);
4401
+ }
4402
+ }
4403
+ return ok;
4404
+ }
4405
+ // ============ Quota Pass-through Methods ============
4406
+ /**
4407
+ * Get quota status for a scope.
4408
+ * Returns null if no quota is configured (fail-open).
4409
+ */
4410
+ async getQuota(scopeId) {
4411
+ if (!this.quotaService) return null;
4412
+ return this.quotaService.getQuota(scopeId);
4413
+ }
4414
+ /**
4415
+ * Set or update the byte limit for a scope.
4416
+ * Creates a quota row if one does not exist.
4417
+ */
4418
+ async setQuotaLimit(scopeId, bytes) {
4419
+ if (!this.quotaService) return null;
4420
+ return this.quotaService.setQuotaLimit(scopeId, bytes);
4421
+ }
4422
+ /**
4423
+ * Recompute and return the quota status for a scope.
4424
+ */
4425
+ async recomputeQuota(scopeId) {
4426
+ if (!this.quotaService) return null;
4427
+ return this.quotaService.recomputeQuota(scopeId);
4428
+ }
4429
+ /**
4430
+ * Increment usage for a scope (admin override — does not throw on exceeded quota).
4431
+ */
4432
+ async incrementQuotaUsage(scopeId, deltaBytes) {
4433
+ if (!this.quotaService) return;
4434
+ return this.quotaService.incrementUsage(scopeId, deltaBytes);
4435
+ }
4436
+ /**
4437
+ * Decrement usage for a scope (e.g. after manual cleanup).
4438
+ */
4439
+ async decrementQuotaUsage(scopeId, deltaBytes) {
4440
+ if (!this.quotaService) return;
4441
+ return this.quotaService.decrementUsage(scopeId, deltaBytes);
4317
4442
  }
4318
4443
  /**
4319
4444
  * Find orphaned files (files with zero references)
@@ -4405,6 +4530,139 @@ var TrackedFileManager = class extends FileManager {
4405
4530
  }
4406
4531
  };
4407
4532
  }
4533
+ /**
4534
+ * Import a file from a URL into virtual storage.
4535
+ *
4536
+ * Uses hazo_secure/fetch for SSRF protection (optional peer dependency).
4537
+ * Streams the response to a temp file, counting bytes live.
4538
+ * On cap exceeded: aborts, deletes temp file, throws ImportSizeCapError.
4539
+ * On success: uploads to virtualPath, sets source_url in DB record.
4540
+ *
4541
+ * @param url - URL to fetch
4542
+ * @param virtualPath - Destination virtual path in storage
4543
+ * @param opts.referrer - Optional Referer header to send
4544
+ * @param opts.maxBytes - Maximum response size in bytes (default: 50MB)
4545
+ * @param opts.actor_id - Actor UUID to record in uploaded_by / changed_by
4546
+ */
4547
+ async importFromUrl(url, virtualPath, opts) {
4548
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
4549
+ const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
4550
+ let hazoSecureMod = null;
4551
+ try {
4552
+ hazoSecureMod = await import("hazo_secure/fetch");
4553
+ } catch {
4554
+ return {
4555
+ success: false,
4556
+ error: 'importFromUrl requires the optional peer dependency "hazo_secure" to be installed. Run: npm install hazo_secure'
4557
+ };
4558
+ }
4559
+ const safeFetch = hazoSecureMod.safeFetch;
4560
+ const SafeFetchErrorClass = hazoSecureMod.SafeFetchError;
4561
+ const policy = {
4562
+ blockPrivateIps: true,
4563
+ allowedProtocols: ["https:", "http:"]
4564
+ };
4565
+ if (this.ssrfAllowlist.length > 0) {
4566
+ policy.allowedHosts = this.ssrfAllowlist;
4567
+ }
4568
+ const headers = {};
4569
+ if (opts?.referrer) {
4570
+ headers["Referer"] = opts.referrer;
4571
+ }
4572
+ const tmpFile = path3.join(os.tmpdir(), `hazo_import_${Date.now()}_${Math.random().toString(36).slice(2)}.tmp`);
4573
+ let tmpWriteStream = null;
4574
+ try {
4575
+ const controller = new AbortController();
4576
+ let response;
4577
+ try {
4578
+ response = await safeFetch(url, {
4579
+ policy,
4580
+ headers,
4581
+ signal: controller.signal
4582
+ });
4583
+ } catch (err) {
4584
+ if (SafeFetchErrorClass && err instanceof SafeFetchErrorClass) {
4585
+ const ssrfErr = err;
4586
+ throw new SSRFError(url, ssrfErr.message);
4587
+ }
4588
+ throw err;
4589
+ }
4590
+ if (!response) {
4591
+ return { success: false, error: `No response from ${url}` };
4592
+ }
4593
+ if (!response.ok) {
4594
+ return { success: false, error: `HTTP ${response.status} from ${url}` };
4595
+ }
4596
+ if (!response.body) {
4597
+ return { success: false, error: `No response body from ${url}` };
4598
+ }
4599
+ tmpWriteStream = fs3.createWriteStream(tmpFile);
4600
+ let totalBytes = 0;
4601
+ let capExceeded = false;
4602
+ const reader = response.body.getReader();
4603
+ try {
4604
+ while (true) {
4605
+ const { done, value } = await reader.read();
4606
+ if (done) break;
4607
+ totalBytes += value.length;
4608
+ if (totalBytes > maxBytes) {
4609
+ capExceeded = true;
4610
+ controller.abort();
4611
+ reader.cancel().catch(() => {
4612
+ });
4613
+ break;
4614
+ }
4615
+ await new Promise((resolve2, reject) => {
4616
+ tmpWriteStream.write(value, (err) => err ? reject(err) : resolve2());
4617
+ });
4618
+ }
4619
+ } finally {
4620
+ await new Promise((resolve2) => tmpWriteStream.end(resolve2));
4621
+ }
4622
+ if (capExceeded) {
4623
+ try {
4624
+ fs3.unlinkSync(tmpFile);
4625
+ } catch {
4626
+ }
4627
+ throw new ImportSizeCapError(url, maxBytes);
4628
+ }
4629
+ if (this.quotaService && opts?.scope_id && totalBytes > 0) {
4630
+ await this.quotaService.checkQuota(opts.scope_id, totalBytes);
4631
+ }
4632
+ const uploadResult = await this.uploadFile(tmpFile, virtualPath, {
4633
+ actor_id: opts?.actor_id,
4634
+ scope_id: opts?.scope_id,
4635
+ awaitRecording: true
4636
+ });
4637
+ try {
4638
+ fs3.unlinkSync(tmpFile);
4639
+ } catch {
4640
+ }
4641
+ if (!uploadResult.success) {
4642
+ return { success: false, error: uploadResult.error };
4643
+ }
4644
+ if (this.isTrackingEnabled()) {
4645
+ const record = await this.metadataService.findByPath(virtualPath, this.getStorageType());
4646
+ if (record) {
4647
+ await this.metadataService.updateFields(record.id, { source_url: url });
4648
+ }
4649
+ }
4650
+ return {
4651
+ success: true,
4652
+ data: { virtualPath, size: totalBytes, sourceUrl: url }
4653
+ };
4654
+ } catch (err) {
4655
+ try {
4656
+ fs3.unlinkSync(tmpFile);
4657
+ } catch {
4658
+ }
4659
+ if (err instanceof SSRFError || err instanceof ImportSizeCapError) {
4660
+ throw err;
4661
+ }
4662
+ const msg = err instanceof Error ? err.message : String(err);
4663
+ return { success: false, error: `importFromUrl failed: ${msg}` };
4664
+ }
4665
+ }
4408
4666
  };
4409
4667
  function createTrackedFileManager(options) {
4410
4668
  return new TrackedFileManager(options);
@@ -5463,6 +5721,206 @@ function createUploadExtractService(fileManager, namingService, extractionServic
5463
5721
  return new UploadExtractService(fileManager, namingService, extractionService, defaultContentTagConfig);
5464
5722
  }
5465
5723
 
5724
+ // src/services/quota-service.ts
5725
+ var QuotaService = class {
5726
+ constructor(opts) {
5727
+ this.crud = opts.crudService;
5728
+ this.onThreshold = opts.onThreshold;
5729
+ this.bands = (opts.bands ?? [0.8, 0.95]).slice().sort((a, b) => a - b);
5730
+ this.logger = opts.logger;
5731
+ }
5732
+ /**
5733
+ * Get quota status for a scope.
5734
+ * Returns null if no quota row exists (fail-open = no quota set).
5735
+ */
5736
+ async getQuota(scopeId) {
5737
+ try {
5738
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5739
+ if (!row) return null;
5740
+ return this.rowToStatus(row);
5741
+ } catch (error) {
5742
+ this.logger?.error?.("QuotaService.getQuota failed", {
5743
+ scopeId,
5744
+ error: error instanceof Error ? error.message : String(error)
5745
+ });
5746
+ return null;
5747
+ }
5748
+ }
5749
+ /**
5750
+ * Set (or update) the byte limit for a scope.
5751
+ * Creates a quota row if one does not exist (with byte_used = 0).
5752
+ * Returns the current stored status after upsert.
5753
+ *
5754
+ * @note This method does NOT auto-reconcile byte_used via a SUM query —
5755
+ * it simply upserts the limit and returns the stored row. To reconcile
5756
+ * byte_used against actual file sizes, call recomputeQuota() separately
5757
+ * after a SUM(file_size) query on hazo_files for the scope.
5758
+ */
5759
+ async setQuotaLimit(scopeId, bytes) {
5760
+ try {
5761
+ const existing = await this.crud.findOneBy({ scope_id: scopeId });
5762
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5763
+ if (existing) {
5764
+ await this.crud.updateById(scopeId, {
5765
+ byte_limit: bytes,
5766
+ updated_at: now
5767
+ });
5768
+ } else {
5769
+ await this.crud.insert({
5770
+ scope_id: scopeId,
5771
+ byte_limit: bytes,
5772
+ byte_used: 0,
5773
+ updated_at: now
5774
+ });
5775
+ }
5776
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5777
+ if (!row) {
5778
+ return { scopeId, byteLimit: bytes, byteUsed: 0, percentUsed: 0 };
5779
+ }
5780
+ return this.rowToStatus(row);
5781
+ } catch (error) {
5782
+ this.logger?.error?.("QuotaService.setQuotaLimit failed", {
5783
+ scopeId,
5784
+ error: error instanceof Error ? error.message : String(error)
5785
+ });
5786
+ throw error;
5787
+ }
5788
+ }
5789
+ /**
5790
+ * Recompute byteUsed by reading the current row.
5791
+ * (Full reconciliation against actual file sizes should be done externally
5792
+ * via a SUM query on hazo_files; this method just returns the stored state.)
5793
+ */
5794
+ async recomputeQuota(scopeId) {
5795
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5796
+ if (!row) {
5797
+ throw new Error(`No quota row found for scope ${scopeId}`);
5798
+ }
5799
+ return this.rowToStatus(row);
5800
+ }
5801
+ /**
5802
+ * Pre-upload check ONLY — does NOT increment.
5803
+ * Throws QuotaExceededError if the upload would exceed the limit.
5804
+ * If no quota row exists for the scope, succeeds silently (fail-open).
5805
+ *
5806
+ * Use this before the upload, then call incrementUsage after confirmed success.
5807
+ * This prevents quota inflation when an upload fails mid-stream.
5808
+ */
5809
+ async checkQuota(scopeId, deltaBytes) {
5810
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5811
+ if (!row) {
5812
+ return;
5813
+ }
5814
+ const prevUsed = row.byte_used;
5815
+ const newUsed = prevUsed + deltaBytes;
5816
+ if (newUsed > row.byte_limit) {
5817
+ throw new QuotaExceededError(scopeId, prevUsed, row.byte_limit, deltaBytes);
5818
+ }
5819
+ }
5820
+ /**
5821
+ * Pre-upload check and increment (atomic). Throws QuotaExceededError if the upload
5822
+ * would exceed the limit. If no quota row exists, succeeds silently (fail-open).
5823
+ * Also fires threshold callbacks for any bands crossed by the new usage.
5824
+ *
5825
+ * @deprecated Prefer checkQuota() before upload + incrementUsage() after success.
5826
+ * checkAndIncrement() increments before the upload completes; if the upload
5827
+ * subsequently fails the quota is inflated with no rollback.
5828
+ */
5829
+ async checkAndIncrement(scopeId, deltaBytes) {
5830
+ await this.checkQuota(scopeId, deltaBytes);
5831
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5832
+ if (!row) return;
5833
+ const prevUsed = row.byte_used;
5834
+ const newUsed = prevUsed + deltaBytes;
5835
+ await this.crud.updateById(scopeId, {
5836
+ byte_used: newUsed,
5837
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
5838
+ });
5839
+ this.fireThresholdCallbacks(scopeId, prevUsed, newUsed, row.byte_limit);
5840
+ }
5841
+ /**
5842
+ * Decrement usage (call after soft-delete or hard-delete).
5843
+ * Clamps to zero; no-ops if no quota row.
5844
+ */
5845
+ async decrementUsage(scopeId, deltaBytes) {
5846
+ try {
5847
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5848
+ if (!row) return;
5849
+ const newUsed = Math.max(0, row.byte_used - deltaBytes);
5850
+ await this.crud.updateById(scopeId, {
5851
+ byte_used: newUsed,
5852
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
5853
+ });
5854
+ } catch (error) {
5855
+ this.logger?.error?.("QuotaService.decrementUsage failed", {
5856
+ scopeId,
5857
+ error: error instanceof Error ? error.message : String(error)
5858
+ });
5859
+ }
5860
+ }
5861
+ /**
5862
+ * Increment usage manually (admin override).
5863
+ * Does NOT throw on exceeded quota — admin is explicitly bypassing.
5864
+ */
5865
+ async incrementUsage(scopeId, deltaBytes) {
5866
+ try {
5867
+ const row = await this.crud.findOneBy({ scope_id: scopeId });
5868
+ if (!row) return;
5869
+ const prevUsed = row.byte_used;
5870
+ const newUsed = prevUsed + deltaBytes;
5871
+ await this.crud.updateById(scopeId, {
5872
+ byte_used: newUsed,
5873
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
5874
+ });
5875
+ this.fireThresholdCallbacks(scopeId, prevUsed, newUsed, row.byte_limit);
5876
+ } catch (error) {
5877
+ this.logger?.error?.("QuotaService.incrementUsage failed", {
5878
+ scopeId,
5879
+ error: error instanceof Error ? error.message : String(error)
5880
+ });
5881
+ }
5882
+ }
5883
+ /**
5884
+ * Fire threshold callbacks for all bands crossed going from prevUsed → newUsed.
5885
+ * Bands are sorted ascending so callbacks fire in order (80% before 95%).
5886
+ */
5887
+ fireThresholdCallbacks(scopeId, prevUsed, newUsed, byteLimit) {
5888
+ if (!this.onThreshold || byteLimit <= 0) return;
5889
+ const prevPercent = prevUsed / byteLimit;
5890
+ const newPercent = newUsed / byteLimit;
5891
+ for (const band of this.bands) {
5892
+ if (prevPercent < band && newPercent >= band) {
5893
+ try {
5894
+ this.onThreshold({
5895
+ scopeId,
5896
+ percent: band,
5897
+ bytesUsed: newUsed,
5898
+ byteLimit
5899
+ });
5900
+ } catch (err) {
5901
+ this.logger?.error?.("QuotaService threshold callback threw", {
5902
+ band,
5903
+ error: err instanceof Error ? err.message : String(err)
5904
+ });
5905
+ }
5906
+ }
5907
+ }
5908
+ }
5909
+ rowToStatus(row) {
5910
+ const byteLimit = row.byte_limit;
5911
+ const byteUsed = row.byte_used;
5912
+ return {
5913
+ scopeId: row.scope_id,
5914
+ byteLimit,
5915
+ byteUsed,
5916
+ percentUsed: byteLimit > 0 ? byteUsed / byteLimit : 0
5917
+ };
5918
+ }
5919
+ };
5920
+ function createQuotaService(opts) {
5921
+ return new QuotaService(opts);
5922
+ }
5923
+
5466
5924
  // src/server/factory.ts
5467
5925
  async function createHazoFilesServer(options = {}) {
5468
5926
  const {
@@ -5477,8 +5935,20 @@ async function createHazoFilesServer(options = {}) {
5477
5935
  namingTableName = "hazo_files_naming",
5478
5936
  enableTracking = !!crudService,
5479
5937
  trackDownloads = true,
5480
- defaultContentTagConfig
5938
+ defaultContentTagConfig,
5939
+ ssrf,
5940
+ quotaCrudService,
5941
+ onQuotaThreshold,
5942
+ quotaBands
5481
5943
  } = options;
5944
+ let quotaService;
5945
+ if (quotaCrudService) {
5946
+ quotaService = new QuotaService({
5947
+ crudService: quotaCrudService,
5948
+ onThreshold: onQuotaThreshold,
5949
+ bands: quotaBands
5950
+ });
5951
+ }
5482
5952
  const fileManagerOptions = {
5483
5953
  config,
5484
5954
  crudService,
@@ -5488,7 +5958,9 @@ async function createHazoFilesServer(options = {}) {
5488
5958
  tableName: metadataTableName,
5489
5959
  trackDownloads,
5490
5960
  logErrors: true
5491
- }
5961
+ },
5962
+ quotaService,
5963
+ ssrfAllowlist: ssrf?.allowlist
5492
5964
  };
5493
5965
  const fileManager = await createInitializedTrackedFileManager(fileManagerOptions);
5494
5966
  const metadataService = fileManager.getMetadataService();
@@ -5532,6 +6004,124 @@ async function createBasicFileManager(config, crudService) {
5532
6004
  });
5533
6005
  }
5534
6006
 
6007
+ // src/services/purge-handlers.ts
6008
+ var HAZO_FILES_JOB_TYPES = {
6009
+ PURGE_PLAN: "hazo_files.purge_plan",
6010
+ PURGE_ONE: "hazo_files.purge_one"
6011
+ };
6012
+ function createPurgeJobHandlers(fm, opts) {
6013
+ const { submitJob, logger } = opts ?? {};
6014
+ const purgePlanHandler = async (job) => {
6015
+ const { retentionDays = 30, dryRun = false } = job.payload ?? {};
6016
+ const cutoffMs = retentionDays * 24 * 60 * 60 * 1e3;
6017
+ const cutoffDate = new Date(Date.now() - cutoffMs);
6018
+ const metadataService = fm.getMetadataService();
6019
+ if (!metadataService) {
6020
+ logger?.warn?.("purge_plan: no metadata service available (tracking disabled)");
6021
+ return { purgedCount: 0 };
6022
+ }
6023
+ let allRecords = [];
6024
+ try {
6025
+ const crud = metadataService.crud;
6026
+ if (crud && typeof crud.findBy === "function") {
6027
+ allRecords = await crud.findBy({ status: "soft_deleted" });
6028
+ }
6029
+ } catch (err) {
6030
+ logger?.error?.("purge_plan: failed to query soft-deleted records", {
6031
+ error: err instanceof Error ? err.message : String(err)
6032
+ });
6033
+ return { purgedCount: 0 };
6034
+ }
6035
+ const eligible = allRecords.filter((r) => {
6036
+ if (!r.deleted_at) return false;
6037
+ const deletedAt = new Date(r.deleted_at);
6038
+ return deletedAt < cutoffDate;
6039
+ });
6040
+ logger?.info?.("purge_plan: found eligible records", {
6041
+ total: allRecords.length,
6042
+ eligible: eligible.length,
6043
+ retentionDays,
6044
+ dryRun
6045
+ });
6046
+ if (dryRun) {
6047
+ return {
6048
+ purgedCount: 0,
6049
+ wouldPurge: eligible.map((r) => r.id)
6050
+ };
6051
+ }
6052
+ let purgedCount = 0;
6053
+ if (submitJob) {
6054
+ for (const record of eligible) {
6055
+ try {
6056
+ await submitJob(HAZO_FILES_JOB_TYPES.PURGE_ONE, { fileId: record.id });
6057
+ purgedCount++;
6058
+ } catch (err) {
6059
+ logger?.error?.("purge_plan: failed to submit purge_one job", {
6060
+ fileId: record.id,
6061
+ error: err instanceof Error ? err.message : String(err)
6062
+ });
6063
+ }
6064
+ }
6065
+ } else {
6066
+ for (const record of eligible) {
6067
+ try {
6068
+ await hardDeleteRecord(fm, record, metadataService, logger);
6069
+ purgedCount++;
6070
+ } catch (err) {
6071
+ logger?.error?.("purge_plan: failed to inline-purge record", {
6072
+ fileId: record.id,
6073
+ error: err instanceof Error ? err.message : String(err)
6074
+ });
6075
+ }
6076
+ }
6077
+ }
6078
+ return { purgedCount };
6079
+ };
6080
+ const purgeOneHandler = async (job) => {
6081
+ const { fileId } = job.payload;
6082
+ const metadataService = fm.getMetadataService();
6083
+ if (!metadataService) {
6084
+ logger?.warn?.("purge_one: no metadata service (tracking disabled)", { fileId });
6085
+ return;
6086
+ }
6087
+ const record = await metadataService.findById(fileId);
6088
+ if (!record) {
6089
+ logger?.info?.("purge_one: record not found (already purged)", { fileId });
6090
+ return;
6091
+ }
6092
+ if (record.status !== "soft_deleted") {
6093
+ logger?.warn?.("[hazo_files] purge_one skipping non-soft-deleted file", {
6094
+ fileId,
6095
+ status: record.status
6096
+ });
6097
+ return;
6098
+ }
6099
+ await hardDeleteRecord(fm, record, metadataService, logger);
6100
+ logger?.info?.("purge_one: completed", { fileId, file_path: record.file_path });
6101
+ };
6102
+ return {
6103
+ [HAZO_FILES_JOB_TYPES.PURGE_PLAN]: purgePlanHandler,
6104
+ [HAZO_FILES_JOB_TYPES.PURGE_ONE]: purgeOneHandler
6105
+ };
6106
+ }
6107
+ async function hardDeleteRecord(fm, record, _metadataService, logger) {
6108
+ try {
6109
+ const deleteResult = await fm.deleteFile(record.file_path);
6110
+ if (!deleteResult.success) {
6111
+ logger?.warn?.("purge: physical delete returned error (continuing)", {
6112
+ fileId: record.id,
6113
+ file_path: record.file_path,
6114
+ error: deleteResult.error
6115
+ });
6116
+ }
6117
+ } catch (err) {
6118
+ logger?.warn?.("purge: physical delete threw (continuing)", {
6119
+ fileId: record.id,
6120
+ error: err instanceof Error ? err.message : String(err)
6121
+ });
6122
+ }
6123
+ }
6124
+
5535
6125
  // src/schema/index.ts
5536
6126
  var HAZO_FILES_DEFAULT_TABLE_NAME = "hazo_files";
5537
6127
  var HAZO_FILES_TABLE_SCHEMA = {
@@ -5815,6 +6405,77 @@ function getMigrationV3ForTable(tableName, dbType) {
5815
6405
  backfill: migration.backfill
5816
6406
  };
5817
6407
  }
6408
+ var HAZO_FILES_MIGRATION_V4 = {
6409
+ tableName: HAZO_FILES_DEFAULT_TABLE_NAME,
6410
+ sqlite: {
6411
+ // NOT IDEMPOTENT — SQLite ALTER TABLE does not support IF NOT EXISTS.
6412
+ // Wrap each statement in try/catch when running on SQLite.
6413
+ alterStatements: [
6414
+ "ALTER TABLE hazo_files ADD COLUMN changed_by TEXT",
6415
+ "ALTER TABLE hazo_files ADD COLUMN source_url TEXT"
6416
+ ],
6417
+ indexes: [
6418
+ "CREATE INDEX IF NOT EXISTS idx_hazo_files_changed_by ON hazo_files (changed_by)",
6419
+ "CREATE INDEX IF NOT EXISTS idx_hazo_files_source_url ON hazo_files (source_url)"
6420
+ ],
6421
+ backfill: ""
6422
+ // No backfill needed — columns are nullable
6423
+ },
6424
+ postgres: {
6425
+ alterStatements: [
6426
+ "ALTER TABLE hazo_files ADD COLUMN IF NOT EXISTS changed_by UUID",
6427
+ "ALTER TABLE hazo_files ADD COLUMN IF NOT EXISTS source_url TEXT"
6428
+ ],
6429
+ indexes: [
6430
+ "CREATE INDEX IF NOT EXISTS idx_hazo_files_changed_by ON hazo_files (changed_by)",
6431
+ "CREATE INDEX IF NOT EXISTS idx_hazo_files_source_url ON hazo_files (source_url)"
6432
+ ],
6433
+ backfill: ""
6434
+ // No backfill needed — columns are nullable
6435
+ },
6436
+ newColumns: ["changed_by", "source_url"]
6437
+ };
6438
+ function getMigrationV4ForTable(tableName, dbType) {
6439
+ validateTableName(tableName);
6440
+ const migration = HAZO_FILES_MIGRATION_V4[dbType];
6441
+ const defaultName = HAZO_FILES_MIGRATION_V4.tableName;
6442
+ return {
6443
+ alterStatements: migration.alterStatements.map(
6444
+ (stmt) => stmt.replace(new RegExp(defaultName, "g"), tableName)
6445
+ ),
6446
+ indexes: migration.indexes.map(
6447
+ (idx) => idx.replace(new RegExp(defaultName, "g"), tableName)
6448
+ ),
6449
+ backfill: migration.backfill
6450
+ };
6451
+ }
6452
+ var HAZO_FILE_QUOTAS_DEFAULT_TABLE_NAME = "hazo_file_quotas";
6453
+ var HAZO_FILE_QUOTAS_TABLE_SCHEMA = {
6454
+ tableName: HAZO_FILE_QUOTAS_DEFAULT_TABLE_NAME,
6455
+ sqlite: {
6456
+ ddl: `CREATE TABLE IF NOT EXISTS hazo_file_quotas (
6457
+ scope_id TEXT PRIMARY KEY,
6458
+ byte_limit INTEGER NOT NULL,
6459
+ byte_used INTEGER NOT NULL DEFAULT 0,
6460
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
6461
+ )`,
6462
+ indexes: [
6463
+ "CREATE INDEX IF NOT EXISTS idx_hazo_file_quotas_used ON hazo_file_quotas (byte_used)"
6464
+ ]
6465
+ },
6466
+ postgres: {
6467
+ ddl: `CREATE TABLE IF NOT EXISTS hazo_file_quotas (
6468
+ scope_id UUID PRIMARY KEY,
6469
+ byte_limit BIGINT NOT NULL,
6470
+ byte_used BIGINT NOT NULL DEFAULT 0,
6471
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
6472
+ )`,
6473
+ indexes: [
6474
+ "CREATE INDEX IF NOT EXISTS idx_hazo_file_quotas_used ON hazo_file_quotas (byte_used)"
6475
+ ]
6476
+ },
6477
+ columns: ["scope_id", "byte_limit", "byte_used", "updated_at"]
6478
+ };
5818
6479
 
5819
6480
  // src/migrations/add-reference-tracking.ts
5820
6481
  async function migrateToV2(executor, dbType, tableName) {
@@ -5875,12 +6536,17 @@ try {
5875
6536
  GoogleDriveAuth,
5876
6537
  GoogleDriveModule,
5877
6538
  HAZO_FILES_DEFAULT_TABLE_NAME,
6539
+ HAZO_FILES_JOB_TYPES,
5878
6540
  HAZO_FILES_MIGRATION_V2,
5879
6541
  HAZO_FILES_MIGRATION_V3,
6542
+ HAZO_FILES_MIGRATION_V4,
5880
6543
  HAZO_FILES_NAMING_DEFAULT_TABLE_NAME,
5881
6544
  HAZO_FILES_NAMING_TABLE_SCHEMA,
5882
6545
  HAZO_FILES_TABLE_SCHEMA,
6546
+ HAZO_FILE_QUOTAS_DEFAULT_TABLE_NAME,
6547
+ HAZO_FILE_QUOTAS_TABLE_SCHEMA,
5883
6548
  HazoFilesError,
6549
+ ImportSizeCapError,
5884
6550
  InvalidExtensionError,
5885
6551
  InvalidPathError,
5886
6552
  LLMExtractionService,
@@ -5888,6 +6554,9 @@ try {
5888
6554
  NamingConventionService,
5889
6555
  OperationError,
5890
6556
  PermissionDeniedError,
6557
+ QuotaExceededError,
6558
+ QuotaService,
6559
+ SSRFError,
5891
6560
  SYSTEM_COUNTER_VARIABLES,
5892
6561
  SYSTEM_DATE_VARIABLES,
5893
6562
  SYSTEM_FILE_VARIABLES,
@@ -5923,6 +6592,8 @@ try {
5923
6592
  createLocalModule,
5924
6593
  createModule,
5925
6594
  createNamingConventionService,
6595
+ createPurgeJobHandlers,
6596
+ createQuotaService,
5926
6597
  createTrackedFileManager,
5927
6598
  createUploadExtractService,
5928
6599
  createVariableSegment,
@@ -5951,6 +6622,7 @@ try {
5951
6622
  getMergedData,
5952
6623
  getMigrationForTable,
5953
6624
  getMigrationV3ForTable,
6625
+ getMigrationV4ForTable,
5954
6626
  getMimeType,
5955
6627
  getNameWithoutExtension,
5956
6628
  getNamingSchemaForTable,