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