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.
- package/CHANGE_LOG.md +16 -0
- package/README.md +185 -0
- package/SETUP_CHECKLIST.md +96 -0
- package/dist/index.d.mts +223 -10
- package/dist/index.d.ts +223 -10
- package/dist/index.js +406 -169
- package/dist/index.mjs +406 -169
- package/dist/server/index.d.mts +514 -11
- package/dist/server/index.d.ts +514 -11
- package/dist/server/index.js +947 -171
- package/dist/server/index.mjs +939 -171
- package/docs/superpowers/plans/2026-05-23-test-app-v2-providers.md +968 -0
- package/migrations/004_changed_by.sql +10 -0
- package/migrations/005_source_url.sql +10 -0
- package/migrations/006_quota_tracking.sql +20 -0
- package/package.json +14 -3
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)
|
package/SETUP_CHECKLIST.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|