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.
package/CHANGE_LOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 1.6.0 (2026-05-21)
9
+
10
+ ### Added
11
+ - **R2 Quota tracking**: Per-scope opt-in quota with threshold callbacks (`setQuotaLimit`, `getQuota`, `recomputeQuota`, `onQuotaThreshold`)
12
+ - **R4 URL import**: `importFromUrl(url, path, opts)` with SSRF protection via `hazo_secure`, 50MB default cap, `source_url` column
13
+ - **R6 Purge**: `createPurgeJobHandlers(fm)` factory + `HAZO_FILES_JOB_TYPES` constants for `hazo_jobs` integration
14
+ - **Actor tracking**: Optional `actor_id` on all `TrackedFileManager` mutations → `changed_by`/`uploaded_by` columns
15
+ - **Migrations 004-006**: `changed_by`, `source_url` columns, and `hazo_file_quotas` table
16
+ - `HAZO_FILES_MIGRATION_V4` export for programmatic V4 migration
17
+ - `HAZO_FILE_QUOTAS_TABLE_SCHEMA` export for quota table DDL
18
+ - `QuotaService`, `QuotaExceededError`, `SSRFError`, `ImportSizeCapError` exported from `hazo_files/server`
19
+ - test-app: Quota, URL Import, and Lifecycle pages with interactive scenarios
20
+
21
+ ### Not breaking
22
+ All additions are opt-in or additive. Existing 1.5.x callers work without changes.
23
+
8
24
  ## [1.5.2] - 2026-05-18
9
25
 
10
26
  ### Fixed
package/README.md CHANGED
@@ -21,6 +21,10 @@ A powerful, modular file management package for Node.js and React applications w
21
21
  - **Content Tagging**: Optional LLM-based content classification at upload time or on-demand via `content_tag` field
22
22
  - **Schema Migrations**: Built-in V2/V3 migration utilities for adding reference tracking and content tagging to existing databases
23
23
  - **Background Upload Pipelines**: Framework-agnostic `UploadManager` + React `HazoFileUploadProvider` for multi-step upload pipelines that survive component unmount, with optional sonner toast bridge
24
+ - **Quota Tracking**: Per-scope opt-in quota with threshold callbacks and fail-open semantics
25
+ - **URL Import**: `importFromUrl` with SSRF protection, streaming size cap, and `source_url` provenance tracking
26
+ - **Actor Tracking**: Optional `actor_id` on all mutations, written to `uploaded_by` and `changed_by` columns
27
+ - **Purge Scheduler**: `createPurgeJobHandlers` factory for integrating with hazo_jobs cron workers
24
28
  - **TypeScript**: Full type safety and IntelliSense support
25
29
  - **OAuth Integration**: Built-in Google Drive and Dropbox OAuth authentication
26
30
  - **Prompt Cache Invalidation**: Passthrough for hazo_llm_api prompt cache management via server instance
@@ -1981,6 +1985,187 @@ Contributions are welcome! Please:
1981
1985
  4. Add tests for new functionality
1982
1986
  5. Submit a pull request
1983
1987
 
1988
+ ## Quota Tracking (v1.6.0)
1989
+
1990
+ Per-scope opt-in quota tracking. A scope with no quota row has no limit — uploads succeed regardless (fail-open).
1991
+
1992
+ ### Setup
1993
+
1994
+ Run migration 006 to create the `hazo_file_quotas` table (or use the `HAZO_FILE_QUOTAS_TABLE_SCHEMA` DDL export):
1995
+
1996
+ ```bash
1997
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
1998
+ ```
1999
+
2000
+ Create a CRUD service and pass it to the factory:
2001
+
2002
+ ```typescript
2003
+ import { createHazoFilesServer, HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
2004
+
2005
+ const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
2006
+ primaryKeys: ['scope_id'],
2007
+ autoId: { enabled: false },
2008
+ });
2009
+
2010
+ const { fileManager } = await createHazoFilesServer({
2011
+ crudService: fileCrud,
2012
+ quotaCrudService: quotaCrud,
2013
+ onQuotaThreshold: (event) => {
2014
+ console.log(`Quota ${(event.percent * 100).toFixed(0)}% for scope ${event.scopeId}`);
2015
+ },
2016
+ quotaBands: [0.80, 0.95], // default
2017
+ config: { provider: 'local', local: { basePath: './files' } },
2018
+ });
2019
+ ```
2020
+
2021
+ ### Setting Quota Limits
2022
+
2023
+ ```typescript
2024
+ // Set a 10 GB limit for a scope
2025
+ const status = await fileManager.setQuotaLimit('scope-uuid', 10 * 1024 * 1024 * 1024);
2026
+ // { scopeId, byteLimit, byteUsed, percentUsed }
2027
+
2028
+ // Check quota without uploading
2029
+ const quota = await fileManager.getQuota('scope-uuid');
2030
+ if (!quota) {
2031
+ console.log('No quota set — unlimited');
2032
+ }
2033
+ ```
2034
+
2035
+ ### Uploading with Quota Check
2036
+
2037
+ When `scope_id` is passed to `uploadFile`, the quota is checked before the upload:
2038
+
2039
+ ```typescript
2040
+ // Throws QuotaExceededError if byteUsed + fileSize > byteLimit
2041
+ await fileManager.uploadFile(buffer, '/docs/report.pdf', { scope_id: 'scope-uuid' });
2042
+ ```
2043
+
2044
+ ### Threshold Callbacks
2045
+
2046
+ The `onQuotaThreshold` callback fires for each band crossed in a single operation. Both 80% and 95% can fire from one upload:
2047
+
2048
+ ```typescript
2049
+ onQuotaThreshold: (event) => {
2050
+ if (event.percent === 0.80) notifyUser('Storage 80% full');
2051
+ if (event.percent === 0.95) blockUploads('Storage 95% full');
2052
+ }
2053
+ ```
2054
+
2055
+ ---
2056
+
2057
+ ## URL Import (v1.6.0)
2058
+
2059
+ Import a file directly from a URL into virtual storage. Requires the optional peer dependency `hazo_secure`.
2060
+
2061
+ ### Installation
2062
+
2063
+ ```bash
2064
+ npm install hazo_secure # optional peer dep
2065
+ ```
2066
+
2067
+ ### Usage
2068
+
2069
+ ```typescript
2070
+ const result = await fileManager.importFromUrl(
2071
+ 'https://cdn.example.com/documents/report.pdf',
2072
+ '/imports/report.pdf',
2073
+ {
2074
+ maxBytes: 50 * 1024 * 1024, // 50MB cap (default)
2075
+ referrer: 'https://myapp.example.com/dashboard',
2076
+ actor_id: 'user-uuid', // written to uploaded_by + changed_by
2077
+ }
2078
+ );
2079
+ // result.data: { virtualPath, size, sourceUrl }
2080
+ ```
2081
+
2082
+ The imported file's `source_url` column is set to the fetch URL for provenance tracking.
2083
+
2084
+ ### SSRF Protection
2085
+
2086
+ Configure an allowlist in the server factory to restrict importable domains:
2087
+
2088
+ ```typescript
2089
+ const { fileManager } = await createHazoFilesServer({
2090
+ ssrf: {
2091
+ allowlist: ['cdn.example.com', 'assets.myapp.com'],
2092
+ },
2093
+ // ...
2094
+ });
2095
+ ```
2096
+
2097
+ Without an allowlist, private IPs (RFC-1918) are always blocked. Blocked requests throw `SSRFError`.
2098
+
2099
+ ### Size Cap
2100
+
2101
+ If the response body exceeds `maxBytes`, the fetch is aborted and `ImportSizeCapError` is thrown. No partial file is left in storage.
2102
+
2103
+ ---
2104
+
2105
+ ## Actor Tracking (v1.6.0)
2106
+
2107
+ All `TrackedFileManager` mutation methods accept an optional `actor_id` parameter:
2108
+
2109
+ ```typescript
2110
+ await fileManager.uploadFile(buffer, '/path/file.pdf', { actor_id: 'user-uuid' });
2111
+ await fileManager.moveItem('/old', '/new', { actor_id: 'user-uuid' });
2112
+ await fileManager.renameFile('/old.pdf', 'new.pdf', { actor_id: 'user-uuid' });
2113
+ await fileManager.renameFolder('/old-dir', 'new-dir', { actor_id: 'user-uuid' });
2114
+ await fileManager.deleteFile('/path/file.pdf', { actor_id: 'user-uuid' });
2115
+ await fileManager.softDeleteFile(fileId, { actor_id: 'user-uuid' });
2116
+ ```
2117
+
2118
+ When `actor_id` is provided:
2119
+ - `uploaded_by` is set on creation (uploadFile only)
2120
+ - `changed_by` is written on every mutation
2121
+
2122
+ Run migrations 004 and 005 to add these columns to existing databases:
2123
+
2124
+ ```bash
2125
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
2126
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
2127
+ ```
2128
+
2129
+ ---
2130
+
2131
+ ## Purge via hazo_jobs (v1.6.0)
2132
+
2133
+ `createPurgeJobHandlers` creates handlers compatible with hazo_jobs workers for scheduled hard-deletion of soft-deleted files.
2134
+
2135
+ ```typescript
2136
+ import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
2137
+
2138
+ const handlers = createPurgeJobHandlers(fm, {
2139
+ submitJob: (type, payload) => jobs.submit({ type, payload }),
2140
+ });
2141
+
2142
+ // Register with your hazo_jobs worker:
2143
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
2144
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
2145
+
2146
+ // Create schedule via hazo_jobs REST or admin UI:
2147
+ await jobs.schedules.create({
2148
+ name: 'hazo_files_purge',
2149
+ cron: '30 3 * * *',
2150
+ type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
2151
+ payload: { retentionDays: 30 },
2152
+ });
2153
+ ```
2154
+
2155
+ **purge_plan**: Finds all `soft_deleted` records older than `retentionDays` and submits individual `purge_one` jobs. Supports `dryRun: true` to return `wouldPurge` list without deleting.
2156
+
2157
+ **purge_one**: Hard-deletes a single file's physical storage and DB record. Idempotent — no-op if record not found.
2158
+
2159
+ **Note**: Quota is decremented at soft-delete time, not at purge time.
2160
+
2161
+ ---
2162
+
2163
+ ## Image Processing
2164
+
2165
+ Image resizing, thumbnail generation, and EXIF extraction are provided by the companion package `hazo_images`, which depends on `hazo_files` for storage.
2166
+
2167
+ ---
2168
+
1984
2169
  ## Support
1985
2170
 
1986
2171
  - GitHub Issues: [https://github.com/pub12/hazo_files/issues](https://github.com/pub12/hazo_files/issues)
@@ -1297,6 +1297,102 @@ Run through this final checklist to ensure everything is working:
1297
1297
  - Verify hazo_files/ui is imported correctly
1298
1298
  - Check browser console for errors
1299
1299
 
1300
+ ## Part 9: V4 Setup (1.6.0 Features)
1301
+
1302
+ ### 9.1 Run Migrations 004-006
1303
+
1304
+ - [ ] Add actor tracking columns:
1305
+ ```bash
1306
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/004_changed_by.sql
1307
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/005_source_url.sql
1308
+ ```
1309
+ *(For SQLite: run the commented-out `ALTER TABLE` statements manually)*
1310
+
1311
+ - [ ] Create quota tracking table:
1312
+ ```bash
1313
+ psql $DATABASE_URL < node_modules/hazo_files/migrations/006_quota_tracking.sql
1314
+ ```
1315
+
1316
+ ### 9.2 Quota Tracking (optional)
1317
+
1318
+ - [ ] Create CRUD service for `hazo_file_quotas`:
1319
+ ```typescript
1320
+ import { HAZO_FILE_QUOTAS_TABLE_SCHEMA } from 'hazo_files/server';
1321
+
1322
+ const quotaCrud = createCrudService(adapter, HAZO_FILE_QUOTAS_TABLE_SCHEMA.tableName, {
1323
+ primaryKeys: ['scope_id'],
1324
+ autoId: { enabled: false },
1325
+ });
1326
+ ```
1327
+
1328
+ - [ ] Pass to server factory:
1329
+ ```typescript
1330
+ const { fileManager } = await createHazoFilesServer({
1331
+ quotaCrudService: quotaCrud,
1332
+ onQuotaThreshold: (event) => {
1333
+ console.log(`Scope ${event.scopeId} at ${(event.percent * 100).toFixed(0)}%`);
1334
+ },
1335
+ // ... other options
1336
+ });
1337
+ ```
1338
+
1339
+ - [ ] Set quota limits for scopes:
1340
+ ```typescript
1341
+ await fileManager.setQuotaLimit(scopeId, 10 * 1024 * 1024 * 1024); // 10 GB
1342
+ ```
1343
+
1344
+ ### 9.3 URL Import / SSRF Guard (optional)
1345
+
1346
+ - [ ] Install optional peer dependency:
1347
+ ```bash
1348
+ npm install hazo_secure
1349
+ ```
1350
+
1351
+ - [ ] Configure SSRF allowlist in factory:
1352
+ ```typescript
1353
+ const { fileManager } = await createHazoFilesServer({
1354
+ ssrf: { allowlist: ['cdn.example.com', 'assets.myapp.com'] },
1355
+ // ...
1356
+ });
1357
+ ```
1358
+
1359
+ - [ ] Use `importFromUrl` in your API route:
1360
+ ```typescript
1361
+ const result = await fileManager.importFromUrl(url, '/imports/file.pdf', {
1362
+ actor_id: userId,
1363
+ maxBytes: 50 * 1024 * 1024,
1364
+ });
1365
+ ```
1366
+
1367
+ ### 9.4 Purge Scheduler via hazo_jobs (optional)
1368
+
1369
+ - [ ] Install hazo_jobs (separate package):
1370
+ ```bash
1371
+ npm install hazo_jobs
1372
+ ```
1373
+
1374
+ - [ ] Register purge handlers with your worker:
1375
+ ```typescript
1376
+ import { createPurgeJobHandlers, HAZO_FILES_JOB_TYPES } from 'hazo_files/server';
1377
+
1378
+ const handlers = createPurgeJobHandlers(fm, {
1379
+ submitJob: (type, payload) => jobs.submit({ type, payload }),
1380
+ });
1381
+
1382
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_PLAN, handlers[HAZO_FILES_JOB_TYPES.PURGE_PLAN]);
1383
+ worker.register(HAZO_FILES_JOB_TYPES.PURGE_ONE, handlers[HAZO_FILES_JOB_TYPES.PURGE_ONE]);
1384
+ ```
1385
+
1386
+ - [ ] Create recurring schedule:
1387
+ ```typescript
1388
+ await jobs.schedules.create({
1389
+ name: 'hazo_files_purge',
1390
+ cron: '30 3 * * *', // 3:30 AM daily
1391
+ type: HAZO_FILES_JOB_TYPES.PURGE_PLAN,
1392
+ payload: { retentionDays: 30 },
1393
+ });
1394
+ ```
1395
+
1300
1396
  ## Next Steps
1301
1397
 
1302
1398
  - [ ] Review [README.md](README.md) for advanced usage examples
package/dist/index.d.mts CHANGED
@@ -187,6 +187,10 @@ interface FileMetadataInput {
187
187
  original_filename?: string;
188
188
  /** Content tag classifying the document type (V3) */
189
189
  content_tag?: string;
190
+ /** Actor UUID who performed this mutation (V4) */
191
+ changed_by?: string;
192
+ /** Source URL when file was imported via importFromUrl (V4) */
193
+ source_url?: string;
190
194
  }
191
195
  /**
192
196
  * Input for updating an existing metadata record
@@ -433,6 +437,10 @@ interface FileMetadataRecordV2 extends FileMetadataRecord {
433
437
  deleted_at?: string | null;
434
438
  /** Content tag classifying the document type (V3) */
435
439
  content_tag?: string | null;
440
+ /** Actor UUID who last mutated this record (V4 — migration 004) */
441
+ changed_by?: string | null;
442
+ /** Source URL when file was imported via importFromUrl (V4 — migration 005) */
443
+ source_url?: string | null;
436
444
  }
437
445
  /**
438
446
  * Options for adding a reference to a file
@@ -770,8 +778,9 @@ declare class FileMetadataService {
770
778
  recordAccess(path: string, storageType: StorageProvider): Promise<boolean>;
771
779
  /**
772
780
  * Record a file deletion
781
+ * changedBy is accepted for API consistency but not written (record is deleted)
773
782
  */
774
- recordDelete(path: string, storageType: StorageProvider): Promise<boolean>;
783
+ recordDelete(path: string, storageType: StorageProvider, _changedBy?: string): Promise<boolean>;
775
784
  /**
776
785
  * Record a directory deletion (recursive)
777
786
  */
@@ -779,11 +788,11 @@ declare class FileMetadataService {
779
788
  /**
780
789
  * Record a file or folder move
781
790
  */
782
- recordMove(sourcePath: string, destinationPath: string, storageType: StorageProvider): Promise<boolean>;
791
+ recordMove(sourcePath: string, destinationPath: string, storageType: StorageProvider, changedBy?: string): Promise<boolean>;
783
792
  /**
784
793
  * Record a file or folder rename
785
794
  */
786
- recordRename(path: string, newName: string, storageType: StorageProvider): Promise<boolean>;
795
+ recordRename(path: string, newName: string, storageType: StorageProvider, changedBy?: string): Promise<boolean>;
787
796
  /**
788
797
  * Find a record by path and storage type
789
798
  */
@@ -887,9 +896,9 @@ declare class FileMetadataService {
887
896
  */
888
897
  softDelete(fileId: string): Promise<boolean>;
889
898
  /**
890
- * Update specific V2 fields on a record
899
+ * Update specific V2/V4 fields on a record
891
900
  */
892
- updateFields(fileId: string, fields: Partial<Pick<FileMetadataRecordV2, 'scope_id' | 'uploaded_by' | 'original_filename' | 'storage_verified_at' | 'status' | 'content_tag'>>): Promise<boolean>;
901
+ updateFields(fileId: string, fields: Partial<Pick<FileMetadataRecordV2, 'scope_id' | 'uploaded_by' | 'original_filename' | 'storage_verified_at' | 'status' | 'content_tag' | 'changed_by' | 'source_url'>>): Promise<boolean>;
893
902
  /**
894
903
  * Find orphaned files (zero references)
895
904
  */
@@ -1053,6 +1062,138 @@ declare function createFileManager(options?: FileManagerOptions): FileManager;
1053
1062
  */
1054
1063
  declare function createInitializedFileManager(options?: FileManagerOptions): Promise<FileManager>;
1055
1064
 
1065
+ /**
1066
+ * Quota Service
1067
+ * Per-scope opt-in quota tracking with threshold callbacks.
1068
+ *
1069
+ * A scope without a hazo_file_quotas row has no quota and uploads succeed (fail-open).
1070
+ */
1071
+ /**
1072
+ * Quota status for a scope
1073
+ */
1074
+ interface QuotaStatus {
1075
+ scopeId: string;
1076
+ byteLimit: number;
1077
+ byteUsed: number;
1078
+ percentUsed: number;
1079
+ }
1080
+ /**
1081
+ * Event emitted when usage crosses a configured threshold band
1082
+ */
1083
+ interface QuotaThresholdEvent {
1084
+ scopeId: string;
1085
+ /** Fractional threshold crossed, e.g. 0.80 or 0.95 */
1086
+ percent: number;
1087
+ bytesUsed: number;
1088
+ byteLimit: number;
1089
+ }
1090
+ /**
1091
+ * Raw row shape from hazo_file_quotas table
1092
+ */
1093
+ interface QuotaRow {
1094
+ scope_id: string;
1095
+ byte_limit: number;
1096
+ byte_used: number;
1097
+ updated_at: string;
1098
+ [key: string]: unknown;
1099
+ }
1100
+ /**
1101
+ * Minimal interface for the quota CRUD service (hazo_connect compatible)
1102
+ */
1103
+ interface QuotaCrudServiceLike {
1104
+ findBy(criteria: Record<string, unknown>): Promise<QuotaRow[]>;
1105
+ findOneBy(criteria: Record<string, unknown>): Promise<QuotaRow | null>;
1106
+ insert(data: Partial<QuotaRow> | Partial<QuotaRow>[]): Promise<QuotaRow[]>;
1107
+ updateById(id: unknown, patch: Partial<QuotaRow>): Promise<QuotaRow[]>;
1108
+ list(configure?: (qb: unknown) => unknown): Promise<QuotaRow[]>;
1109
+ }
1110
+ /**
1111
+ * Options for QuotaService
1112
+ */
1113
+ interface QuotaServiceOptions {
1114
+ /** hazo_connect CRUD service pointed at hazo_file_quotas table */
1115
+ crudService: QuotaCrudServiceLike;
1116
+ /** Callback fired when usage crosses a threshold band */
1117
+ onThreshold?: (event: QuotaThresholdEvent) => void;
1118
+ /** Fractional threshold bands (default: [0.80, 0.95]) */
1119
+ bands?: number[];
1120
+ /** Logger for diagnostics */
1121
+ logger?: {
1122
+ debug?(message: string, data?: Record<string, unknown>): void;
1123
+ warn?(message: string, data?: Record<string, unknown>): void;
1124
+ error?(message: string, data?: Record<string, unknown>): void;
1125
+ };
1126
+ }
1127
+ /**
1128
+ * Per-scope opt-in quota tracking.
1129
+ *
1130
+ * Fail-open: a scope without a quota row has no quota and all uploads succeed.
1131
+ */
1132
+ declare class QuotaService {
1133
+ private crud;
1134
+ private onThreshold?;
1135
+ private bands;
1136
+ private logger?;
1137
+ constructor(opts: QuotaServiceOptions);
1138
+ /**
1139
+ * Get quota status for a scope.
1140
+ * Returns null if no quota row exists (fail-open = no quota set).
1141
+ */
1142
+ getQuota(scopeId: string): Promise<QuotaStatus | null>;
1143
+ /**
1144
+ * Set (or update) the byte limit for a scope.
1145
+ * Creates a quota row if one does not exist (with byte_used = 0).
1146
+ * Returns the current stored status after upsert.
1147
+ *
1148
+ * @note This method does NOT auto-reconcile byte_used via a SUM query —
1149
+ * it simply upserts the limit and returns the stored row. To reconcile
1150
+ * byte_used against actual file sizes, call recomputeQuota() separately
1151
+ * after a SUM(file_size) query on hazo_files for the scope.
1152
+ */
1153
+ setQuotaLimit(scopeId: string, bytes: number): Promise<QuotaStatus>;
1154
+ /**
1155
+ * Recompute byteUsed by reading the current row.
1156
+ * (Full reconciliation against actual file sizes should be done externally
1157
+ * via a SUM query on hazo_files; this method just returns the stored state.)
1158
+ */
1159
+ recomputeQuota(scopeId: string): Promise<QuotaStatus>;
1160
+ /**
1161
+ * Pre-upload check ONLY — does NOT increment.
1162
+ * Throws QuotaExceededError if the upload would exceed the limit.
1163
+ * If no quota row exists for the scope, succeeds silently (fail-open).
1164
+ *
1165
+ * Use this before the upload, then call incrementUsage after confirmed success.
1166
+ * This prevents quota inflation when an upload fails mid-stream.
1167
+ */
1168
+ checkQuota(scopeId: string, deltaBytes: number): Promise<void>;
1169
+ /**
1170
+ * Pre-upload check and increment (atomic). Throws QuotaExceededError if the upload
1171
+ * would exceed the limit. If no quota row exists, succeeds silently (fail-open).
1172
+ * Also fires threshold callbacks for any bands crossed by the new usage.
1173
+ *
1174
+ * @deprecated Prefer checkQuota() before upload + incrementUsage() after success.
1175
+ * checkAndIncrement() increments before the upload completes; if the upload
1176
+ * subsequently fails the quota is inflated with no rollback.
1177
+ */
1178
+ checkAndIncrement(scopeId: string, deltaBytes: number): Promise<void>;
1179
+ /**
1180
+ * Decrement usage (call after soft-delete or hard-delete).
1181
+ * Clamps to zero; no-ops if no quota row.
1182
+ */
1183
+ decrementUsage(scopeId: string, deltaBytes: number): Promise<void>;
1184
+ /**
1185
+ * Increment usage manually (admin override).
1186
+ * Does NOT throw on exceeded quota — admin is explicitly bypassing.
1187
+ */
1188
+ incrementUsage(scopeId: string, deltaBytes: number): Promise<void>;
1189
+ /**
1190
+ * Fire threshold callbacks for all bands crossed going from prevUsed → newUsed.
1191
+ * Bands are sorted ascending so callbacks fire in order (80% before 95%).
1192
+ */
1193
+ private fireThresholdCallbacks;
1194
+ private rowToStatus;
1195
+ }
1196
+
1056
1197
  /**
1057
1198
  * Tracked File Manager
1058
1199
  * Extends FileManager to add database tracking of file operations
@@ -1068,6 +1209,10 @@ interface TrackedFileManagerFullOptions extends FileManagerOptions {
1068
1209
  tracking?: DatabaseTrackingConfig;
1069
1210
  /** Logger for structured file operation logging */
1070
1211
  logger?: MetadataLogger;
1212
+ /** Optional quota service for per-scope upload limits */
1213
+ quotaService?: QuotaService;
1214
+ /** SSRF allowlist passed to importFromUrl (host strings) */
1215
+ ssrfAllowlist?: string[];
1071
1216
  }
1072
1217
  /**
1073
1218
  * Extended upload options with hash tracking
@@ -1081,6 +1226,10 @@ interface TrackedUploadOptions extends UploadOptions {
1081
1226
  * Set to true when you need to immediately query/update the file record.
1082
1227
  */
1083
1228
  awaitRecording?: boolean;
1229
+ /** Actor ID (UUID) to record in uploaded_by and changed_by columns */
1230
+ actor_id?: string;
1231
+ /** Scope ID for quota tracking and organizational grouping */
1232
+ scope_id?: string;
1084
1233
  }
1085
1234
  /**
1086
1235
  * TrackedFileManager - File manager with database tracking
@@ -1091,6 +1240,8 @@ interface TrackedUploadOptions extends UploadOptions {
1091
1240
  declare class TrackedFileManager extends FileManager {
1092
1241
  private metadataService;
1093
1242
  private trackingConfig;
1243
+ private quotaService;
1244
+ private ssrfAllowlist;
1094
1245
  constructor(options?: TrackedFileManagerFullOptions);
1095
1246
  /**
1096
1247
  * Check if tracking is enabled and service is available
@@ -1120,19 +1271,27 @@ declare class TrackedFileManager extends FileManager {
1120
1271
  /**
1121
1272
  * Move a file or folder and update its path in the database
1122
1273
  */
1123
- moveItem(sourcePath: string, destinationPath: string, options?: MoveOptions): Promise<OperationResult<FileSystemItem>>;
1274
+ moveItem(sourcePath: string, destinationPath: string, options?: MoveOptions & {
1275
+ actor_id?: string;
1276
+ }): Promise<OperationResult<FileSystemItem>>;
1124
1277
  /**
1125
1278
  * Delete a file and remove its record from the database
1126
1279
  */
1127
- deleteFile(path: string): Promise<OperationResult>;
1280
+ deleteFile(path: string, opts?: {
1281
+ actor_id?: string;
1282
+ }): Promise<OperationResult>;
1128
1283
  /**
1129
1284
  * Rename a file and update its record in the database
1130
1285
  */
1131
- renameFile(path: string, newName: string, options?: RenameOptions): Promise<OperationResult<FileItem>>;
1286
+ renameFile(path: string, newName: string, options?: RenameOptions & {
1287
+ actor_id?: string;
1288
+ }): Promise<OperationResult<FileItem>>;
1132
1289
  /**
1133
1290
  * Rename a folder and update its record in the database
1134
1291
  */
1135
- renameFolder(path: string, newName: string, options?: RenameOptions): Promise<OperationResult<FolderItem>>;
1292
+ renameFolder(path: string, newName: string, options?: RenameOptions & {
1293
+ actor_id?: string;
1294
+ }): Promise<OperationResult<FolderItem>>;
1136
1295
  /**
1137
1296
  * Write a file with string content and track it
1138
1297
  */
@@ -1211,8 +1370,33 @@ declare class TrackedFileManager extends FileManager {
1211
1370
  getFilesById(fileIds: string[]): Promise<FileWithStatus[]>;
1212
1371
  /**
1213
1372
  * Soft-delete a file (marks as soft_deleted, does not remove physical file)
1373
+ * Also decrements quota usage if quotaService is configured.
1374
+ */
1375
+ softDeleteFile(fileId: string, opts?: {
1376
+ actor_id?: string;
1377
+ }): Promise<boolean>;
1378
+ /**
1379
+ * Get quota status for a scope.
1380
+ * Returns null if no quota is configured (fail-open).
1381
+ */
1382
+ getQuota(scopeId: string): Promise<QuotaStatus | null>;
1383
+ /**
1384
+ * Set or update the byte limit for a scope.
1385
+ * Creates a quota row if one does not exist.
1386
+ */
1387
+ setQuotaLimit(scopeId: string, bytes: number): Promise<QuotaStatus | null>;
1388
+ /**
1389
+ * Recompute and return the quota status for a scope.
1214
1390
  */
1215
- softDeleteFile(fileId: string): Promise<boolean>;
1391
+ recomputeQuota(scopeId: string): Promise<QuotaStatus | null>;
1392
+ /**
1393
+ * Increment usage for a scope (admin override — does not throw on exceeded quota).
1394
+ */
1395
+ incrementQuotaUsage(scopeId: string, deltaBytes: number): Promise<void>;
1396
+ /**
1397
+ * Decrement usage for a scope (e.g. after manual cleanup).
1398
+ */
1399
+ decrementQuotaUsage(scopeId: string, deltaBytes: number): Promise<void>;
1216
1400
  /**
1217
1401
  * Find orphaned files (files with zero references)
1218
1402
  */
@@ -1235,6 +1419,31 @@ declare class TrackedFileManager extends FileManager {
1235
1419
  file_id?: string;
1236
1420
  ref_id?: string;
1237
1421
  }>>;
1422
+ /**
1423
+ * Import a file from a URL into virtual storage.
1424
+ *
1425
+ * Uses hazo_secure/fetch for SSRF protection (optional peer dependency).
1426
+ * Streams the response to a temp file, counting bytes live.
1427
+ * On cap exceeded: aborts, deletes temp file, throws ImportSizeCapError.
1428
+ * On success: uploads to virtualPath, sets source_url in DB record.
1429
+ *
1430
+ * @param url - URL to fetch
1431
+ * @param virtualPath - Destination virtual path in storage
1432
+ * @param opts.referrer - Optional Referer header to send
1433
+ * @param opts.maxBytes - Maximum response size in bytes (default: 50MB)
1434
+ * @param opts.actor_id - Actor UUID to record in uploaded_by / changed_by
1435
+ */
1436
+ importFromUrl(url: string, virtualPath: string, opts?: {
1437
+ referrer?: string;
1438
+ maxBytes?: number;
1439
+ actor_id?: string;
1440
+ /** Scope ID for quota checking and tracking. If provided, quota is checked before upload. */
1441
+ scope_id?: string;
1442
+ }): Promise<OperationResult<{
1443
+ virtualPath: string;
1444
+ size: number;
1445
+ sourceUrl: string;
1446
+ }>>;
1238
1447
  }
1239
1448
  /**
1240
1449
  * Create a new TrackedFileManager instance
@@ -1821,6 +2030,10 @@ interface HazoFilesColumnDefinitions {
1821
2030
  original_filename: 'TEXT';
1822
2031
  /** Content tag classifying the document type (V3) */
1823
2032
  content_tag: 'TEXT';
2033
+ /** UUID of the actor who last mutated this record (V4 — migration 004) */
2034
+ changed_by: 'TEXT' | 'UUID';
2035
+ /** Source URL when file was imported via importFromUrl (V4 — migration 005) */
2036
+ source_url: 'TEXT';
1824
2037
  }
1825
2038
  /**
1826
2039
  * Schema definition for a specific database type