@anteros/core 0.0.40 → 0.0.42

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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,133 @@
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
+ case 'agent.generate':
111
+ case 'agent.stream':
112
+ case 'agent.object':
113
+ // The model's answer is never stored — only the envelope that proves the
114
+ // run happened and what it cost (nothing the caller did not already send).
115
+ return result ? {
116
+ finishReason: result.finishReason ?? null,
117
+ usage: result.usage ?? null,
118
+ tools: Array.isArray(result.toolCalls)
119
+ ? result.toolCalls.map((call: any) => call?.name).filter(Boolean)
120
+ : [],
121
+ steps: Array.isArray(result.steps) ? result.steps.length : null,
122
+ } : null
123
+ default:
124
+ // Anything unknown: a document must never be stored.
125
+ return null
126
+ }
127
+ }
128
+
129
+ export {
130
+ IDS_MAX,
131
+ auditResultsMode,
132
+ summarizeAuditResult,
133
+ }
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 {
@@ -142,14 +159,54 @@ function Middleware(config: GlobalMiddlewareConfig): GlobalMiddlewareConfig {
142
159
  }
143
160
 
144
161
  import type { WorkflowDefinition } from "../types/workflow";
162
+ import type { AgentConfig, AgentTool, AgentToolDefinition } from "../types/agent";
145
163
 
146
164
  export function Workflow<TData = any>(workflow: WorkflowDefinition<TData>): WorkflowDefinition<TData> {
147
165
  return {
148
166
  ...workflow,
167
+ enabled: workflow.enabled ?? true,
149
168
  _isWorkflow_: true,
150
169
  }
151
170
  }
152
171
 
172
+ /**
173
+ * Declares an LLM agent — `{tenant.dir}/agents/**\/*.agent.ts`.
174
+ *
175
+ * `id` and `description` are **required**: the id identifies it (and looks it up),
176
+ * the description says what it is for — it is served by `info` and read by humans.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * export default define.Agent({
181
+ * id: 'weather',
182
+ * description: 'Answers weather questions with a live forecast.',
183
+ * instructions: 'You are a concise weather assistant.',
184
+ * provider: { model: 'gpt-4o-mini', compatible: 'openai' },
185
+ * tools: { forecast },
186
+ * })
187
+ * ```
188
+ */
189
+ function Agent(config: AgentConfig & { id: string; description: string }): AgentConfig {
190
+ return {
191
+ ...config,
192
+ enabled: config.enabled ?? true,
193
+ _isAgent_: true,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Declares an agent tool — `define.Tool({ id, description, inputSchema, execute })`.
199
+ * The `id` is **required** (it is the name the model calls).
200
+ * A `define.McpTool` is accepted by an agent as-is.
201
+ */
202
+ function Tool(tool: AgentToolDefinition): AgentToolDefinition {
203
+ return {
204
+ ...tool,
205
+ enabled: tool.enabled ?? true,
206
+ _isTool_: true,
207
+ }
208
+ }
209
+
153
210
 
154
211
 
155
212
  export const define = {
@@ -167,5 +224,9 @@ export const define = {
167
224
  Middleware,
168
225
  WebSocket,
169
226
  McpTool,
170
- McpResource
227
+ McpResource,
228
+ Lifecycle,
229
+ Vars,
230
+ Agent,
231
+ Tool,
171
232
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The URL of everything — one place.
3
+ *
4
+ * Every framework surface lives under a **single namespace**, `/api/:tenant_id`,
5
+ * and a **family** segment says what it is:
6
+ *
7
+ * | Surface | Route |
8
+ * | --- | --- |
9
+ * | Collections (CRUD, actions, login…) | `POST /api/:tenant_id/collections/:collection/:action` |
10
+ * | Services | `POST /api/:tenant_id/services/:service/:action` |
11
+ * | Variables | `POST /api/:tenant_id/vars/:action` |
12
+ * | Upload | `POST /api/:tenant_id/upload/:collection` |
13
+ * | Files (serve, delete) | `GET\|DELETE /api/:tenant_id/files/:collection/:file` |
14
+ * | Agents | `POST /api/:tenant_id/agents/:agent/:action` |
15
+ * | MCP (tools & resources) | `GET\|POST /api/:tenant_id/mcp` |
16
+ *
17
+ * Two reasons for the shape. A tenant's own routes (`routes: { prefix: '/api/v1' }`)
18
+ * are mounted **before** this one, so they always win — and nothing is ambiguous:
19
+ * a collection may be called `services` or `login` without ever colliding with the
20
+ * family segment, since a collection always sits under `/collections/`.
21
+ *
22
+ * `patterns` is what the server registers (Hono patterns); `endpoints` is what a
23
+ * client builds — the two are read together, so a path is never written twice.
24
+ */
25
+
26
+ /** The family segments — the component right after the tenant id. */
27
+ const FAMILIES = ["collections", "services", "vars", "upload", "files", "agents", "mcp"] as const;
28
+
29
+ const patterns = {
30
+ collection: "/api/:tenant_id/collections/:collection/:action",
31
+ /** The login action of an auth-enabled collection — its own rate limit. */
32
+ login: "/api/:tenant_id/collections/:collection/login",
33
+ service: "/api/:tenant_id/services/:service/:action",
34
+ vars: "/api/:tenant_id/vars/:action",
35
+ upload: "/api/:tenant_id/upload/:collection",
36
+ file: "/api/:tenant_id/files/:collection/:file",
37
+ agent: "/api/:tenant_id/agents/:agent/:action",
38
+ mcp: "/api/:tenant_id/mcp",
39
+ } as const;
40
+
41
+ /** One path segment, encoded. */
42
+ function segment(value: string): string {
43
+ return encodeURIComponent(String(value));
44
+ }
45
+
46
+ const endpoints = {
47
+ /** Everything of a collection — `find`, `insertOne`, `login`, a custom action… */
48
+ collection: (tenant: string, collection: string, action: string) =>
49
+ `/api/${segment(tenant)}/collections/${segment(collection)}/${segment(action)}`,
50
+
51
+ service: (tenant: string, service: string, action: string) =>
52
+ `/api/${segment(tenant)}/services/${segment(service)}/${segment(action)}`,
53
+
54
+ /** Variables — the namespace and the key travel in the body. */
55
+ vars: (tenant: string, action: string) =>
56
+ `/api/${segment(tenant)}/vars/${segment(action)}`,
57
+
58
+ upload: (tenant: string, collection: string) =>
59
+ `/api/${segment(tenant)}/upload/${segment(collection)}`,
60
+
61
+ /** One file of a file collection — the document id, or its filename. */
62
+ file: (tenant: string, collection: string, file: string) =>
63
+ `/api/${segment(tenant)}/files/${segment(collection)}/${segment(file)}`,
64
+
65
+ agent: (tenant: string, agent: string, action: string) =>
66
+ `/api/${segment(tenant)}/agents/${segment(agent)}/${segment(action)}`,
67
+
68
+ mcp: (tenant: string) => `/api/${segment(tenant)}/mcp`,
69
+ } as const;
70
+
71
+ /**
72
+ * Is this a segment the framework owns? A tenant route mounted at
73
+ * `/api/:tenant_id/<family>/…` is registered **first** and would shadow the
74
+ * framework API silently — the route loader warns about it.
75
+ */
76
+ function isReservedFamily(name: string): boolean {
77
+ return (FAMILIES as readonly string[]).includes(name);
78
+ }
79
+
80
+ export { endpoints, FAMILIES, isReservedFamily, patterns, segment };
package/lib/files.ts CHANGED
@@ -1,7 +1,11 @@
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";
8
+ import { endpoints } from "./endpoints";
5
9
  import { S3Client } from "bun";
6
10
  import type { FileCollection } from "../types/file";
7
11
  import path from "path";
@@ -13,6 +17,10 @@ import { ObjectId } from "mongodb";
13
17
 
14
18
  export type FileResult = {
15
19
  _id: string;
20
+ /** ISO string — set on upload */
21
+ createdAt: string;
22
+ /** ISO string — refreshed when the metadata is completed */
23
+ updatedAt: string;
16
24
  _file: {
17
25
  filename: string;
18
26
  name: string;
@@ -54,7 +62,7 @@ export function createDiskStorage(baseDir?: string): FileStorage {
54
62
  const filename = `${meta.id}${ext}`;
55
63
  const filepath = path.join(dir, filename);
56
64
  const buffer = await file.arrayBuffer();
57
- // Faille 17: 'wx' — fail if file already exists (no silent overwrite)
65
+ // Flaw 17: 'wx' — fail if file already exists (no silent overwrite)
58
66
  await fs.writeFile(filepath, new Uint8Array(buffer), { flag: 'wx' });
59
67
  const outPath = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
60
68
  return { path: outPath, size: buffer.byteLength };
@@ -135,7 +143,7 @@ async function transformImage(
135
143
  const format = options.format || 'webp';
136
144
  const quality = options.quality ?? 80;
137
145
 
138
- // Faille 20: validate params (reject NaN / out-of-range)
146
+ // Flaw 20: validate params (reject NaN / out-of-range)
139
147
  if (!Number.isFinite(quality) || quality < 1 || quality > 100) {
140
148
  throw new AppError('Invalid quality parameter', { code: 'INVALID_TRANSFORM', status: 400 });
141
149
  }
@@ -144,7 +152,7 @@ async function transformImage(
144
152
  throw new AppError('Invalid width/height parameter', { code: 'INVALID_TRANSFORM', status: 400 });
145
153
  }
146
154
 
147
- // Convertir ReadableStream en Uint8Array si nécessaire
155
+ // Convert a ReadableStream to a Uint8Array if needed
148
156
  let buffer: Uint8Array;
149
157
  if (input instanceof Uint8Array) {
150
158
  buffer = input;
@@ -165,13 +173,13 @@ async function transformImage(
165
173
  }
166
174
  }
167
175
 
168
- // Faille 20: only transform real images (magic bytes check)
176
+ // Flaw 20: only transform real images (magic bytes check)
169
177
  const detected = detectMimeFromBuffer(buffer);
170
178
  if (!detected || !detected.startsWith('image/')) {
171
179
  throw new AppError('Not an image file', { code: 'NOT_AN_IMAGE', status: 400 });
172
180
  }
173
181
 
174
- // Utiliser Bun.Image (natif, zéro dépendance)
182
+ // Use Bun.Image (native, zero dependencies)
175
183
  const img = new Bun.Image(buffer);
176
184
 
177
185
  if (options.width || options.height) {
@@ -181,7 +189,7 @@ async function transformImage(
181
189
  });
182
190
  }
183
191
 
184
- // Encoder dans le format demandé
192
+ // Encode in the requested format
185
193
  let out: Blob;
186
194
  switch (format) {
187
195
  case 'jpeg':
@@ -236,6 +244,7 @@ export function getStorageForCollection(slug: string, tenant_id: string): FileSt
236
244
  * Handle a file upload for a given collection.
237
245
  */
238
246
  export async function handleUpload(options: UploadOptions): Promise<FileResult> {
247
+ const startedAt = Date.now();
239
248
  const { collection, tenant_id, file, data } = options;
240
249
 
241
250
  const col = getFileCollection(collection, tenant_id);
@@ -253,7 +262,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
253
262
  });
254
263
  }
255
264
 
256
- // Faille 4: validate magic bytes — don't trust client-declared Content-Type
265
+ // Flaw 4: validate magic bytes — don't trust client-declared Content-Type
257
266
  const fullBuffer = new Uint8Array(await file.arrayBuffer());
258
267
  const probe = fullBuffer.subarray(0, 4096);
259
268
  const detected = detectMimeFromBuffer(probe);
@@ -283,6 +292,9 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
283
292
  });
284
293
  }
285
294
 
295
+ // Timestamps (ISO strings) — stored and returned in the response
296
+ const now = new Date()
297
+
286
298
  // Insert metadata first to get the MongoDB auto-generated ObjectId
287
299
  let _id: string;
288
300
  try {
@@ -290,6 +302,8 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
290
302
  // Form-data / JSON always sends IDs as strings, but MongoDB expects
291
303
  // ObjectId for fields that reference other collections.
292
304
  const insertDoc: Record<string, any> = {
305
+ createdAt: now,
306
+ updatedAt: now,
293
307
  _file: {
294
308
  filename: '',
295
309
  name: file.name,
@@ -325,7 +339,13 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
325
339
  }
326
340
  }
327
341
  const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
328
- const result = await rest.db.collection(collection).insertOne(insertDoc);
342
+ // Convert the custom fields exactly like a regular collection insert
343
+ // (ISO date strings → Date, hex ids → ObjectId, …) so file metadata matches
344
+ // the collections' storage types. `_file` is framework-generated and must
345
+ // stay untouched (a filename could otherwise look like a date or an ObjectId).
346
+ const { _file, ...fields } = insertDoc;
347
+ const stored = { _file, ...toBson<Record<string, any>>(fields, { col } as any) };
348
+ const result = await rest.db.collection(collection).insertOne(stored as any);
329
349
  _id = String(result.insertedId);
330
350
  } catch (err) {
331
351
  throw new AppError('Failed to save file metadata', {
@@ -341,12 +361,12 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
341
361
  const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id: _id, mimetype, subpath });
342
362
 
343
363
  // Update MongoDB with actual file info
344
- const url = `/files/${tenant_id}/${collection}/${filename}`;
364
+ const url = endpoints.file(tenant_id, collection, filename);
345
365
  try {
346
366
  const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
347
367
  await rest.db.collection(collection).updateOne(
348
368
  { _id: new ObjectId(_id) },
349
- { $set: { '_file.filename': filename, '_file.size': size, '_file.url': url } },
369
+ { $set: { '_file.filename': filename, '_file.size': size, '_file.url': url, updatedAt: new Date() } },
350
370
  );
351
371
  } catch (err) {
352
372
  await storage.delete(tenant_id, collection, _id, filename, subpath).catch(() => {});
@@ -362,8 +382,26 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
362
382
  });
363
383
  }
364
384
 
385
+ // Audit the upload — the file metadata and the generated id, never the stored
386
+ // document (see the Audit page: parameters only). `internal` is taken from the
387
+ // request context, so an HTTP upload is logged as such.
388
+ try {
389
+ await new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false }).audit.log({
390
+ action: 'upload',
391
+ collection,
392
+ input: {
393
+ file: { name: file.name, mimetype, size },
394
+ fields: Object.keys(data ?? {}),
395
+ },
396
+ result: { _id },
397
+ duration: Date.now() - startedAt,
398
+ });
399
+ } catch { /* auditing must never fail an upload */ }
400
+
365
401
  return {
366
402
  _id,
403
+ createdAt: now.toISOString(),
404
+ updatedAt: now.toISOString(),
367
405
  _file: {
368
406
  filename,
369
407
  name: file.name,
@@ -391,7 +429,7 @@ export async function handleServe(
391
429
  const stream = await storage.getStream(tenant_id, collection, fileId, filename, subpath);
392
430
  if (!stream) return null;
393
431
 
394
- // Faille 10: serve the MIME validated at upload time (content-checked),
432
+ // Flaw 10: serve the MIME validated at upload time (content-checked),
395
433
  // not re-derived from the extension.
396
434
  let mimetype: string | undefined;
397
435
  try {
@@ -412,7 +450,7 @@ export async function handleServe(
412
450
  return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
413
451
  }
414
452
 
415
- // Faille 4: SVG is served as an attachment (never inline) to prevent stored XSS
453
+ // Flaw 4: SVG is served as an attachment (never inline) to prevent stored XSS
416
454
  if (isSvg) {
417
455
  return { stream, mimetype, attachment: true };
418
456
  }
@@ -477,6 +515,7 @@ export async function handleDelete(
477
515
  collection: string,
478
516
  fileId: string,
479
517
  ): Promise<void> {
518
+ const startedAt = Date.now();
480
519
  const storage = getStorageForCollection(collection, tenant_id);
481
520
  const col = getFileCollection(collection, tenant_id);
482
521
  const subpath = col?.storage?.path || undefined;
@@ -511,6 +550,21 @@ export async function handleDelete(
511
550
  await rest.db.collection(collection).deleteOne({
512
551
  _id: (ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId) as any,
513
552
  });
553
+ // This path bypasses the collection hooks — record a tombstone explicitly so the
554
+ // deletion propagates when the file collection opted into replication.
555
+ if (col?.replication?.enabled) {
556
+ const db = getTenant(tenant_id)?.database?.db;
557
+ if (db) await writeDeleteMarkers(db, tenant_id, collection, [fileId]);
558
+ }
559
+ // Audited like any other delete: the id and the stored filename, nothing else.
560
+ // (`internal` is taken from the request context, so an HTTP call is logged as such.)
561
+ await rest.audit.log({
562
+ action: 'deleteFile',
563
+ collection,
564
+ input: { id: fileId, filename },
565
+ result: { deleted: true },
566
+ duration: Date.now() - startedAt,
567
+ });
514
568
  } catch (err: any) {
515
569
  console.error('Failed to delete file metadata:', err?.message || err);
516
570
  }