@anteros/core 0.0.40 → 0.0.41

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.
Files changed (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
package/lib/audit.ts ADDED
@@ -0,0 +1,271 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { cfg } from "../server/config";
4
+ import type { Tenant } from "../types/tenant";
5
+ import { parseDuration } from "../utils/func";
6
+ import { resolveAuditRetention } from "../database/audit";
7
+
8
+ /**
9
+ * Local, append-only backup of the audit trail — one JSONL file per day,
10
+ * rotated on size and pruned by age. Meant for operators who want a copy of
11
+ * `_audit_` on the same machine as the logs, independent from MongoDB.
12
+ */
13
+ export type AuditFileOptions = {
14
+ /** Target directory — default: `<server.logging.dir>/audit` (`.logs/audit`) */
15
+ dir?: string;
16
+ /**
17
+ * Delete files older than this duration (`'30d'`).
18
+ * `false` → never prune. Omitted → mirrors `audit.retention` when set, else keeps files forever.
19
+ */
20
+ retention?: string | false;
21
+ /** Rotate when the current file exceeds this size in bytes (default: 50MB) */
22
+ maxSize?: number;
23
+ };
24
+
25
+ /** `true` → defaults, `string` → directory shorthand, object → full options */
26
+ export type AuditFileConfig = boolean | string | AuditFileOptions;
27
+
28
+ type ResolvedAuditFile = {
29
+ dir: string;
30
+ retentionMs: number | null;
31
+ maxSize: number;
32
+ };
33
+
34
+ type Sink = ResolvedAuditFile & {
35
+ /** Current file ('' until opened) */
36
+ file: string;
37
+ /** `YYYY-MM-DD` of the current file */
38
+ day: string;
39
+ size: number;
40
+ pending: string[];
41
+ writing: boolean;
42
+ prunedAt: number;
43
+ inFlight: Promise<void>;
44
+ };
45
+
46
+ const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50MB
47
+ const PRUNE_INTERVAL_MS = 5 * 60 * 1000; // prune at most every 5 minutes
48
+ const EXT = '.jsonl';
49
+
50
+ const sinks = new Map<string, Sink>();
51
+
52
+ /**
53
+ * Resolve the file backup settings — **the tenant always wins**,
54
+ * `server.audit.file` is only the fallback. Returns `null` when disabled.
55
+ */
56
+ function resolveAuditFile(tenant: Tenant): ResolvedAuditFile | null {
57
+ const raw: AuditFileConfig | undefined = tenant.audit?.file !== undefined
58
+ ? tenant.audit.file
59
+ : cfg.server?.audit?.file
60
+
61
+ if (raw === undefined || raw === false) return null
62
+
63
+ const options: AuditFileOptions = raw === true
64
+ ? {}
65
+ : typeof raw === 'string' ? { dir: raw } : raw
66
+
67
+ const dir = options.dir ?? path.join(cfg.server?.logging?.dir ?? '.logs', 'audit')
68
+
69
+ // Retention: explicit value wins (including `false`), otherwise the local
70
+ // copy mirrors the database retention so both follow the same policy.
71
+ const retention = options.retention !== undefined ? options.retention : resolveAuditRetention(tenant)
72
+ let retentionMs: number | null = null
73
+ if (retention !== undefined && retention !== false) {
74
+ const parsed = parseDuration(retention)
75
+ if (parsed === null) {
76
+ console.error(`Invalid audit file retention '${retention}' — expected a duration like '30d' or '24h'`)
77
+ } else {
78
+ retentionMs = parsed
79
+ }
80
+ }
81
+
82
+ return { dir, retentionMs, maxSize: options.maxSize ?? DEFAULT_MAX_SIZE }
83
+ }
84
+
85
+ function prefix(tenantId: string): string {
86
+ return `audit-${tenantId}`
87
+ }
88
+
89
+ function escapeRegex(value: string): string {
90
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
+ }
92
+
93
+ /** `JSON.stringify` that never throws (an activity must never break a request) */
94
+ function safeStringify(value: unknown): string {
95
+ try {
96
+ return JSON.stringify(value)
97
+ } catch {
98
+ return JSON.stringify({ error: 'unserializable activity' })
99
+ }
100
+ }
101
+
102
+ function getSink(key: string, resolved: ResolvedAuditFile): Sink {
103
+ let sink = sinks.get(key)
104
+ if (!sink) {
105
+ sink = {
106
+ ...resolved,
107
+ file: '',
108
+ day: '',
109
+ size: 0,
110
+ pending: [],
111
+ writing: false,
112
+ prunedAt: 0,
113
+ inFlight: Promise.resolve(),
114
+ }
115
+ sinks.set(key, sink)
116
+ } else {
117
+ // Options may change between boots / tests — always latest wins
118
+ sink.dir = resolved.dir
119
+ sink.retentionMs = resolved.retentionMs
120
+ sink.maxSize = resolved.maxSize
121
+ }
122
+ return sink
123
+ }
124
+
125
+ /**
126
+ * Pick the file `day` should be appended to: `audit-<tenant>-<day>.jsonl`,
127
+ * then `-1`, `-2`… when the size cap is reached.
128
+ *
129
+ * Files of the day are scanned in rotation order and the first one with room is
130
+ * reused — so a restart mid-day keeps appending to the current file, and the cap
131
+ * is never exceeded (a single line larger than the cap gets its own file).
132
+ */
133
+ async function openFile(sink: Sink, tenantId: string, day: string, bytes: number): Promise<void> {
134
+ await fs.mkdir(sink.dir, { recursive: true })
135
+
136
+ const base = `${prefix(tenantId)}-${day}`
137
+ const re = new RegExp(`^${escapeRegex(base)}(?:-(\\d+))?${escapeRegex(EXT)}$`)
138
+
139
+ const candidates: { index: number; name: string; size: number }[] = []
140
+ try {
141
+ for (const entry of await fs.readdir(sink.dir)) {
142
+ const match = entry.match(re)
143
+ if (!match) continue
144
+ try {
145
+ candidates.push({
146
+ index: Number(match[1] ?? 0),
147
+ name: entry,
148
+ size: (await fs.stat(path.join(sink.dir, entry))).size,
149
+ })
150
+ } catch { /* vanished between readdir and stat */ }
151
+ }
152
+ } catch { /* unreadable dir — treated as empty */ }
153
+
154
+ candidates.sort((a, b) => a.index - b.index)
155
+
156
+ const target = candidates.find((candidate) => candidate.size + bytes <= sink.maxSize)
157
+ if (target) {
158
+ sink.file = path.join(sink.dir, target.name)
159
+ sink.day = day
160
+ sink.size = target.size
161
+ return
162
+ }
163
+
164
+ const next = candidates.length ? candidates[candidates.length - 1]!.index + 1 : 0
165
+ sink.file = path.join(sink.dir, next === 0 ? `${base}${EXT}` : `${base}-${next}${EXT}`)
166
+ sink.day = day
167
+ sink.size = 0
168
+ }
169
+
170
+ /** Delete this tenant's audit files older than `retentionMs` (by mtime). Returns the removed names. */
171
+ async function pruneAuditFiles(dir: string, tenantId: string, retentionMs: number): Promise<string[]> {
172
+ const removed: string[] = []
173
+ try {
174
+ const re = new RegExp(`^${escapeRegex(prefix(tenantId))}-.*${escapeRegex(EXT)}$`)
175
+ const cutoff = Date.now() - retentionMs
176
+
177
+ for (const entry of await fs.readdir(dir)) {
178
+ if (!re.test(entry)) continue
179
+ const full = path.join(dir, entry)
180
+ try {
181
+ const stat = await fs.stat(full)
182
+ if (stat.mtimeMs < cutoff) {
183
+ await fs.unlink(full)
184
+ removed.push(entry)
185
+ }
186
+ } catch { /* already gone */ }
187
+ }
188
+ } catch { /* dir missing — nothing to prune */ }
189
+
190
+ return removed
191
+ }
192
+
193
+ async function maybePrune(sink: Sink, tenantId: string): Promise<void> {
194
+ if (sink.retentionMs === null) return
195
+ if (Date.now() - sink.prunedAt < PRUNE_INTERVAL_MS) return
196
+ sink.prunedAt = Date.now()
197
+ const removed = await pruneAuditFiles(sink.dir, tenantId, sink.retentionMs)
198
+ if (removed.length) {
199
+ console.log(`🧹 Audit files pruned (${tenantId}): ${removed.length} file(s)`)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Serialized per tenant: concurrent callers coalesce their lines into a single
205
+ * append, order is preserved, and lines are never interleaved.
206
+ */
207
+ async function drain(key: string, tenantId: string): Promise<void> {
208
+ const sink = sinks.get(key)
209
+ if (!sink || sink.writing) return
210
+ sink.writing = true
211
+
212
+ try {
213
+ while (sink.pending.length) {
214
+ const lines = sink.pending.splice(0)
215
+ const batch = lines.join('\n') + '\n'
216
+ const bytes = Buffer.byteLength(batch)
217
+ const day = new Date().toISOString().slice(0, 10)
218
+
219
+ try {
220
+ if (!sink.file || sink.day !== day || sink.size + bytes > sink.maxSize) {
221
+ await openFile(sink, tenantId, day, bytes)
222
+ }
223
+ await fs.appendFile(sink.file, batch)
224
+ } catch (err) {
225
+ // Keep the lines for the next call instead of losing them
226
+ sink.pending.unshift(...lines)
227
+ throw err
228
+ }
229
+
230
+ sink.size += bytes
231
+ await maybePrune(sink, tenantId)
232
+ }
233
+ } catch (err: any) {
234
+ console.error(`Audit file write failed (${tenantId})`, err?.message)
235
+ } finally {
236
+ sink.writing = false
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Append activities to the tenant's local audit file — opt-in, fire-and-forget:
242
+ * it never blocks the caller and never throws.
243
+ */
244
+ function writeAuditFile(tenant: Tenant | null | undefined, activities: unknown[]): void {
245
+ if (!tenant || !activities?.length) return
246
+
247
+ const resolved = resolveAuditFile(tenant)
248
+ if (!resolved) return
249
+
250
+ const key = `${tenant.id}:${resolved.dir}`
251
+ const sink = getSink(key, resolved)
252
+ for (const activity of activities) sink.pending.push(safeStringify(activity))
253
+
254
+ sink.inFlight = drain(key, tenant.id).catch(() => {})
255
+ }
256
+
257
+ /** Wait until every pending line is on disk (tests, graceful shutdown). */
258
+ async function flushAuditFile(): Promise<void> {
259
+ for (let i = 0; i < 20; i++) {
260
+ const current = [...sinks.values()]
261
+ await Promise.all(current.map((sink) => sink.inFlight.catch(() => {})))
262
+ if (!current.some((sink) => sink.writing || sink.pending.length)) return
263
+ }
264
+ }
265
+
266
+ export {
267
+ resolveAuditFile,
268
+ writeAuditFile,
269
+ flushAuditFile,
270
+ pruneAuditFiles
271
+ }
@@ -0,0 +1,120 @@
1
+ import { cfg } from "../server/config";
2
+
3
+ /**
4
+ * What an audit entry keeps of an operation's **result** (default: `summary`).
5
+ *
6
+ * - `summary` → the **generated identifiers** (`_id` / `insertedIds`), plus the
7
+ * operational counters (`matchedCount`, `deletedCount`,
8
+ * `nIndexesWas`, …). Never a document.
9
+ * - `none` → the identifiers only.
10
+ * - `full` → the raw result as returned by the operation (documents included).
11
+ */
12
+ export type AuditResults = 'none' | 'summary' | 'full'
13
+
14
+ /** Cap on the identifiers kept for a batch insert — the count stays exact */
15
+ const IDS_MAX = 100
16
+
17
+ const COUNTER_KEYS = [
18
+ 'acknowledged',
19
+ 'matchedCount',
20
+ 'modifiedCount',
21
+ 'upsertedCount',
22
+ 'upsertedId',
23
+ 'insertedCount',
24
+ 'deletedCount',
25
+ 'insertedIds',
26
+ 'upsertedIds',
27
+ ]
28
+
29
+ // Default state (`results` unset = `summary`) — a sentinel like `undefined`
30
+ // would be indistinguishable from the real, unset configuration.
31
+ let cachedConfig: unknown = undefined
32
+ let cachedMode: AuditResults = 'summary'
33
+
34
+ function auditResultsMode(): AuditResults {
35
+ const configured = cfg.server?.audit?.results
36
+ if (configured !== cachedConfig) {
37
+ cachedConfig = configured
38
+ cachedMode = configured === 'full' || configured === 'none' ? configured : 'summary'
39
+ }
40
+ return cachedMode
41
+ }
42
+
43
+ /** Generated identifiers of an operation whose result is a document — never the document */
44
+ function identifiers(action: string, result: any): any {
45
+ // `insertOne`, `updateOne`, `findOneAndUpdate` and a file `upload` all return
46
+ // the document (created / updated / resulting) — only its `_id` may enter the trail.
47
+ if (action === 'insertOne' || action === 'updateOne' || action === 'findOneAndUpdate' || action === 'upload') {
48
+ return result?._id ? { _id: result._id } : null
49
+ }
50
+ if (action === 'insertMany') {
51
+ const ids = Array.isArray(result)
52
+ ? result.map((doc) => doc?._id).filter((id) => typeof id === 'string')
53
+ : []
54
+ return {
55
+ count: ids.length,
56
+ ...(ids.length ? { insertedIds: ids.slice(0, IDS_MAX) } : {}),
57
+ ...(ids.length > IDS_MAX ? { truncated: true } : {}),
58
+ }
59
+ }
60
+ return null
61
+ }
62
+
63
+ /** Pick the counter keys actually present in a driver result */
64
+ function counters(result: any): any {
65
+ const out: any = {}
66
+ for (const key of COUNTER_KEYS) {
67
+ if (result?.[key] !== undefined) out[key] = result[key]
68
+ }
69
+ return Object.keys(out).length ? out : null
70
+ }
71
+
72
+ /**
73
+ * Reduce an operation's result to what the audit trail may store.
74
+ * `operation.input` already holds the parameters (and, for writes, the change
75
+ * itself) — the result is only useful as *proof the operation had an effect*.
76
+ */
77
+ function summarizeAuditResult(action: string, result: any): any {
78
+ const mode = auditResultsMode()
79
+ if (mode === 'full') return result ?? null
80
+ if (result === null || result === undefined) return null
81
+
82
+ if (mode === 'none') return identifiers(action, result)
83
+
84
+ switch (action) {
85
+ case 'insertOne':
86
+ case 'insertMany':
87
+ case 'updateOne':
88
+ case 'findOneAndUpdate':
89
+ case 'upload':
90
+ // Document-returning operations: identifiers only
91
+ return identifiers(action, result)
92
+ case 'updateMany':
93
+ case 'bulkWrite':
94
+ case 'bulkUpdate':
95
+ return counters(result)
96
+ case 'deleteOne':
97
+ case 'deleteFile':
98
+ // `deleteOne` returns the deleted document — keep only "it happened"
99
+ return { deleted: true }
100
+ case 'deleteMany':
101
+ return counters(result)
102
+ case 'countDocuments':
103
+ return typeof result === 'number' ? result : null
104
+ case 'dropCollection':
105
+ return typeof result === 'boolean' ? result : null
106
+ case 'dropIndex':
107
+ return typeof result === 'string' ? result : null
108
+ case 'dropIndexes':
109
+ return result?.nIndexesWas !== undefined ? { nIndexesWas: result.nIndexesWas } : null
110
+ default:
111
+ // Anything unknown: a document must never be stored.
112
+ return null
113
+ }
114
+ }
115
+
116
+ export {
117
+ IDS_MAX,
118
+ auditResultsMode,
119
+ summarizeAuditResult,
120
+ }
package/lib/define.ts CHANGED
@@ -8,6 +8,8 @@ import type { Service } from "../types/service";
8
8
  import type { WebSocketHandler } from "../types/websocket";
9
9
  import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware";
10
10
  import type { McpTool, McpResource } from "../types/mcp";
11
+ import type { Lifecycle } from "../types/lifecycle";
12
+ import type { VarDefinition } from "../types/vars";
11
13
  import { file } from "bun";
12
14
  function Server(config: ServerConfig) {
13
15
  config.server = {
@@ -100,6 +102,21 @@ function Script(config: Script) {
100
102
  }
101
103
  }
102
104
 
105
+ function Lifecycle(config: Lifecycle): Lifecycle {
106
+ return {
107
+ ...config,
108
+ enabled: config.enabled ?? true,
109
+ _isLifecycle_: true,
110
+ }
111
+ }
112
+
113
+ function Vars(config: VarDefinition): VarDefinition {
114
+ return {
115
+ ...config,
116
+ _isVars_: true,
117
+ }
118
+ }
119
+
103
120
 
104
121
  function Route(route: Route): Route {
105
122
  return {
@@ -167,5 +184,7 @@ export const define = {
167
184
  Middleware,
168
185
  WebSocket,
169
186
  McpTool,
170
- McpResource
187
+ McpResource,
188
+ Lifecycle,
189
+ Vars
171
190
  }
package/lib/files.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { getFileCollection } from "../database/file";
2
+ import { getTenant } from "../database/tenant";
3
+ import { writeDeleteMarkers } from "../database/deleteLog";
4
+ import { toBson } from "../utils/func";
2
5
  import { useRest } from "../database/rest";
3
6
  import { cfg } from "../server/config";
4
7
  import { AppError } from "./error";
@@ -13,6 +16,10 @@ import { ObjectId } from "mongodb";
13
16
 
14
17
  export type FileResult = {
15
18
  _id: string;
19
+ /** ISO string — set on upload */
20
+ createdAt: string;
21
+ /** ISO string — refreshed when the metadata is completed */
22
+ updatedAt: string;
16
23
  _file: {
17
24
  filename: string;
18
25
  name: string;
@@ -54,7 +61,7 @@ export function createDiskStorage(baseDir?: string): FileStorage {
54
61
  const filename = `${meta.id}${ext}`;
55
62
  const filepath = path.join(dir, filename);
56
63
  const buffer = await file.arrayBuffer();
57
- // Faille 17: 'wx' — fail if file already exists (no silent overwrite)
64
+ // Flaw 17: 'wx' — fail if file already exists (no silent overwrite)
58
65
  await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
59
66
  const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
60
67
  return { path: outPath, size: buffer.byteLength };
@@ -135,7 +142,7 @@ async function transformImage(
135
142
  const format = options.format || 'webp';
136
143
  const quality = options.quality ?? 80;
137
144
 
138
- // Faille 20: validate params (reject NaN / out-of-range)
145
+ // Flaw 20: validate params (reject NaN / out-of-range)
139
146
  if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
140
147
  throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
141
148
  }
@@ -144,7 +151,7 @@ async function transformImage(
144
151
  throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
145
152
  }
146
153
 
147
- // Convertir ReadableStream en Uint8Array si nécessaire
154
+ // Convert a ReadableStream to a Uint8Array if needed
148
155
  let buffer: Uint8Array;
149
156
  if (input instanceof Uint8Array) {
150
157
  buffer = input;
@@ -165,13 +172,13 @@ async function transformImage(
165
172
  }
166
173
  }
167
174
 
168
- // Faille 20: only transform real images (magic bytes check)
175
+ // Flaw 20: only transform real images (magic bytes check)
169
176
  const detected = detectMimeFromBuffer(buffer);
170
177
  if (!detected || !detected.startsWith('image/')) {
171
178
  throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
172
179
  }
173
180
 
174
- // Utiliser Bun.Image (natif, zéro dépendance)
181
+ // Use Bun.Image (native, zero dependencies)
175
182
  const img = new Bun.Image(buffer);
176
183
 
177
184
  if (options.width || options.height) {
@@ -181,7 +188,7 @@ async function transformImage(
181
188
  });
182
189
  }
183
190
 
184
- // Encoder dans le format demandé
191
+ // Encode in the requested format
185
192
  let out: Blob;
186
193
  switch (format) {
187
194
  case 'jpeg':
@@ -236,6 +243,7 @@ export function getStorageForCollection(slug: string, tenant_id: string): FileSt
236
243
  * Handle a file upload for a given collection.
237
244
  */
238
245
  export async function handleUpload(options: UploadOptions): Promise<FileResult> {
246
+ const startedAt = Date.now();
239
247
  const { collection, tenant_id, file, data } = options;
240
248
 
241
249
  const col = getFileCollection(collection, tenant_id);
@@ -253,7 +261,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
253
261
  });
254
262
  }
255
263
 
256
- // Faille 4: validate magic bytes — don't trust client-declared Content-Type
264
+ // Flaw 4: validate magic bytes — don't trust client-declared Content-Type
257
265
  const fullBuffer = new Uint8Array(await file.arrayBuffer());
258
266
  const probe = fullBuffer.subarray(0, 4096);
259
267
  const detected = detectMimeFromBuffer(probe);
@@ -283,6 +291,9 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
283
291
  });
284
292
  }
285
293
 
294
+ // Timestamps (ISO strings) — stored and returned in the response
295
+ const now = new Date()
296
+
286
297
  // Insert metadata first to get the MongoDB auto-generated ObjectId
287
298
  let _id: string;
288
299
  try {
@@ -290,6 +301,8 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
290
301
  // Form-data / JSON always sends IDs as strings, but MongoDB expects
291
302
  // ObjectId for fields that reference other collections.
292
303
  const insertDoc: Record<string, any> = {
304
+ createdAt: now,
305
+ updatedAt: now,
293
306
  _file: {
294
307
  filename: '',
295
308
  name: file.name,
@@ -325,7 +338,13 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
325
338
  }
326
339
  }
327
340
  const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
328
- const result = await rest.db.collection(collection).insertOne(insertDoc);
341
+ // Convert the custom fields exactly like a regular collection insert
342
+ // (ISO date strings → Date, hex ids → ObjectId, …) so file metadata matches
343
+ // the collections' storage types. `_file` is framework-generated and must
344
+ // stay untouched (a filename could otherwise look like a date or an ObjectId).
345
+ const { _file, ...fields } = insertDoc;
346
+ const stored = { _file, ...toBson<Record<string, any>>(fields, { col } as any) };
347
+ const result = await rest.db.collection(collection).insertOne(stored as any);
329
348
  _id = String(result.insertedId);
330
349
  } catch (err) {
331
350
  throw new AppError('Failed to save file metadata', {
@@ -346,7 +365,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
346
365
  const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
347
366
  await rest.db.collection(collection).updateOne(
348
367
  { _id: new ObjectId(_id) },
349
- { $set: { '_file.filename': filename, '_file.size': size, '_file.url': url } },
368
+ { $set: { '_file.filename': filename, '_file.size': size, '_file.url': url, updatedAt: new Date() } },
350
369
  );
351
370
  } catch (err) {
352
371
  await storage.delete(tenant_id, collection, _id, filename, subpath).catch(() => {});
@@ -362,8 +381,26 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
362
381
  });
363
382
  }
364
383
 
384
+ // Audit the upload — the file metadata and the generated id, never the stored
385
+ // document (see the Audit page: parameters only). `internal` is taken from the
386
+ // request context, so an HTTP upload is logged as such.
387
+ try {
388
+ await new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false }).audit.log({
389
+ action: 'upload',
390
+ collection,
391
+ input: {
392
+ file: { name: file.name, mimetype, size },
393
+ fields: Object.keys(data ?? {}),
394
+ },
395
+ result: { _id },
396
+ duration: Date.now() - startedAt,
397
+ });
398
+ } catch { /* auditing must never fail an upload */ }
399
+
365
400
  return {
366
401
  _id,
402
+ createdAt: now.toISOString(),
403
+ updatedAt: now.toISOString(),
367
404
  _file: {
368
405
  filename,
369
406
  name: file.name,
@@ -391,7 +428,7 @@ export async function handleServe(
391
428
  const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
392
429
  if (!stream) return null;
393
430
 
394
- // Faille 10: serve the MIME validated at upload time (content-checked),
431
+ // Flaw 10: serve the MIME validated at upload time (content-checked),
395
432
  // not re-derived from the extension.
396
433
  let mimetype: string | undefined;
397
434
  try {
@@ -412,7 +449,7 @@ export async function handleServe(
412
449
  return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
413
450
  }
414
451
 
415
- // Faille 4: SVG is served as an attachment (never inline) to prevent stored XSS
452
+ // Flaw 4: SVG is served as an attachment (never inline) to prevent stored XSS
416
453
  if (isSvg) {
417
454
  return { stream, mimetype, attachment: true };
418
455
  }
@@ -477,6 +514,7 @@ export async function handleDelete(
477
514
  collection: string,
478
515
  fileId: string,
479
516
  ): Promise<void> {
517
+ const startedAt = Date.now();
480
518
  const storage = getStorageForCollection(collection, tenant_id);
481
519
  const col = getFileCollection(collection, tenant_id);
482
520
  const subpath = col?.storage?.path || undefined;
@@ -511,6 +549,21 @@ export async function handleDelete(
511
549
  await rest.db.collection(collection).deleteOne({
512
550
  _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
513
551
  });
552
+ // This path bypasses the collection hooks — record a tombstone explicitly so the
553
+ // deletion propagates when the file collection opted into replication.
554
+ if (col?.replication?.enabled) {
555
+ const db = getTenant(tenant_id)?.database?.db;
556
+ if (db) await writeDeleteMarkers(db, tenant_id, collection, [fileId]);
557
+ }
558
+ // Audited like any other delete: the id and the stored filename, nothing else.
559
+ // (`internal` is taken from the request context, so an HTTP call is logged as such.)
560
+ await rest.audit.log({
561
+ action: 'deleteFile',
562
+ collection,
563
+ input: { id: fileId, filename },
564
+ result: { deleted: true },
565
+ duration: Date.now() - startedAt,
566
+ });
514
567
  } catch (err: any) {
515
568
  console.error('Failed to delete file metadata:', err?.message || err);
516
569
  }