@anteros/core 0.0.39 → 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 (64) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +4 -4
  3. package/database/deleteLog.ts +34 -0
  4. package/database/field.crypto.ts +1 -1
  5. package/database/file.ts +3 -3
  6. package/database/mongodbadapter.ts +321 -147
  7. package/database/replication.ts +780 -0
  8. package/database/schema.ts +140 -125
  9. package/database/tenant.ts +4 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +6 -6
  12. package/index.ts +10 -0
  13. package/lib/audit.ts +271 -0
  14. package/lib/auditResult.ts +120 -0
  15. package/lib/define.ts +20 -1
  16. package/lib/files.ts +64 -11
  17. package/lib/lifecycle.ts +141 -0
  18. package/lib/redact.ts +117 -0
  19. package/lib/workflow.ts +1 -1
  20. package/package.json +1 -1
  21. package/server/api.ts +134 -33
  22. package/server/boot.ts +185 -53
  23. package/server/flags.ts +136 -0
  24. package/server/hono.ts +1 -1
  25. package/tests/audit-file.test.ts +296 -0
  26. package/tests/audit-operations.test.ts +184 -0
  27. package/tests/audit-redact.test.ts +233 -0
  28. package/tests/audit-results.test.ts +188 -0
  29. package/tests/audit-retention-config.test.ts +76 -0
  30. package/tests/audit-run-action.test.ts +120 -0
  31. package/tests/audit.test.ts +320 -0
  32. package/tests/crypto.test.ts +17 -17
  33. package/tests/db-cache.test.ts +7 -7
  34. package/tests/field-encryption.test.ts +25 -25
  35. package/tests/file-collection-replication.test.ts +98 -0
  36. package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
  37. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  38. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  39. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  40. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  41. package/tests/fixtures/repl-only/app.ts +26 -0
  42. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  43. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  44. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  45. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  46. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  47. package/tests/index-spec.test.ts +5 -5
  48. package/tests/lifecycle.test.ts +96 -0
  49. package/tests/replication-control.test.ts +128 -0
  50. package/tests/replication-initial-sync.test.ts +86 -0
  51. package/tests/replication-only.test.ts +358 -0
  52. package/tests/replication.test.ts +188 -0
  53. package/tests/upload.test.ts +239 -0
  54. package/tests/vars-api.test.ts +123 -0
  55. package/tests/vars-replication.test.ts +95 -0
  56. package/tests/vars.test.ts +216 -0
  57. package/types/collection.d.ts +3 -0
  58. package/types/config.d.ts +64 -0
  59. package/types/file.d.ts +4 -1
  60. package/types/lifecycle.d.ts +71 -0
  61. package/types/replication.d.ts +199 -0
  62. package/types/tenant.d.ts +23 -0
  63. package/types/vars.d.ts +147 -0
  64. package/utils/func.ts +67 -52
@@ -0,0 +1,141 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { cfg } from "../server/config";
4
+ import { useRest } from "../database/rest";
5
+ import { logger } from "../utils/logger";
6
+ import {
7
+ replicationNow,
8
+ getReplicationState,
9
+ resetReplication,
10
+ seedReplication,
11
+ } from "../database/replication";
12
+ import type { Lifecycle, LifecycleReason } from "../types/lifecycle";
13
+ import type { Tenant } from "../types/tenant";
14
+ import type { TenantReplicationApi } from "../types/replication";
15
+
16
+ /**
17
+ * Tenant-scoped replication API — like `rest`, the tenant is implicit. Built per
18
+ * tenant for the lifecycle context, so hooks never pass a tenant id.
19
+ */
20
+ function tenantReplication(tenantId: string): TenantReplicationApi {
21
+ return {
22
+ now: () => replicationNow(tenantId),
23
+ state: (destinationId?: string, collection?: string) => getReplicationState(tenantId, destinationId, collection),
24
+ reset: (opts) => resetReplication(tenantId, opts),
25
+ seed: (value, opts) => seedReplication(tenantId, value, opts),
26
+ };
27
+ }
28
+
29
+ /** Lifecycles by tenant id — one `{tenant.dir}/lifecycle.ts` per tenant. */
30
+ const lifecycles = new Map<string, Lifecycle>();
31
+ /** Ensures `onDestroy` runs at most once per process. */
32
+ let destroyed = false;
33
+
34
+ /**
35
+ * Load `{tenant.dir}/lifecycle.ts` for every tenant. A missing file is fine —
36
+ * a tenant simply has no lifecycle.
37
+ */
38
+ async function loadLifecycles(): Promise<void> {
39
+ lifecycles.clear();
40
+ destroyed = false;
41
+ for (const tenant of cfg.tenants ?? []) {
42
+ try {
43
+ const file = path.join(process.cwd(), tenant.dir, "lifecycle.ts");
44
+ if (!(await fs.exists(file))) continue;
45
+ const module = await import(file);
46
+ const lifecycle = module?.default;
47
+ if (lifecycle?._isLifecycle_) {
48
+ lifecycles.set(tenant.id, { ...lifecycle, _tenant_: tenant.id });
49
+ }
50
+ } catch (err: any) {
51
+ logger.file("error", "lifecycle: failed to load", { tenant: tenant.id, error: err?.message });
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Tenant-scoped context passed to every hook. */
57
+ function buildContext(tenant: Tenant, extra?: Record<string, any>) {
58
+ return {
59
+ tenant,
60
+ rest: new useRest({ tenant_id: tenant.id }),
61
+ logger,
62
+ cfg,
63
+ replication: tenantReplication(tenant.id),
64
+ ...(extra ?? {}),
65
+ } as any;
66
+ }
67
+
68
+ /**
69
+ * Run every `beforeBoot` hook, sequentially in `cfg.tenants` order. Blocking:
70
+ * a throwing hook aborts the boot (fail-fast).
71
+ */
72
+ async function runBeforeBoot(): Promise<void> {
73
+ for (const tenant of cfg.tenants ?? []) {
74
+ const lifecycle = lifecycles.get(tenant.id);
75
+ if (!lifecycle?.enabled || !lifecycle.beforeBoot) continue;
76
+ try {
77
+ await lifecycle.beforeBoot(buildContext(tenant));
78
+ logger.file("lifecycle: beforeBoot", { tenant: tenant.id });
79
+ } catch (err: any) {
80
+ logger.error(`lifecycle beforeBoot failed for tenant '${tenant.id}'`, { error: err?.message, stack: err?.stack });
81
+ throw new Error(`[${tenant.id}] lifecycle beforeBoot failed: ${err?.message ?? err}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Run every `afterBoot` hook once the server is listening. Errors are logged
88
+ * but never fatal — the server is already serving.
89
+ */
90
+ async function runAfterBoot(server: any, io: any): Promise<void> {
91
+ for (const tenant of cfg.tenants ?? []) {
92
+ const lifecycle = lifecycles.get(tenant.id);
93
+ if (!lifecycle?.enabled || !lifecycle.afterBoot) continue;
94
+ try {
95
+ await lifecycle.afterBoot(buildContext(tenant, { io, server }));
96
+ logger.file("lifecycle: afterBoot", { tenant: tenant.id });
97
+ } catch (err: any) {
98
+ logger.error(`lifecycle afterBoot failed for tenant '${tenant.id}'`, { error: err?.message });
99
+ }
100
+ }
101
+ }
102
+
103
+ /** Bound a promise with a timeout that rejects. */
104
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
105
+ let timer: ReturnType<typeof setTimeout>;
106
+ const timeout = new Promise<never>((_, reject) => {
107
+ timer = setTimeout(() => reject(new Error(message)), ms);
108
+ });
109
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
110
+ }
111
+
112
+ /**
113
+ * Run every `onDestroy` hook on shutdown (once per process), before replication
114
+ * stops and DB clients close. Each hook is bounded by `destroyTimeout`.
115
+ */
116
+ async function runOnDestroy(reason: LifecycleReason): Promise<void> {
117
+ if (destroyed) return;
118
+ destroyed = true;
119
+
120
+ for (const tenant of cfg.tenants ?? []) {
121
+ const lifecycle = lifecycles.get(tenant.id);
122
+ if (!lifecycle?.enabled || !lifecycle.onDestroy) continue;
123
+ try {
124
+ await withTimeout(
125
+ Promise.resolve(lifecycle.onDestroy(buildContext(tenant, { reason }))),
126
+ lifecycle.destroyTimeout ?? 10_000,
127
+ `lifecycle onDestroy timed out after ${lifecycle.destroyTimeout ?? 10_000}ms`,
128
+ );
129
+ logger.file("lifecycle: onDestroy", { tenant: tenant.id, reason });
130
+ } catch (err: any) {
131
+ logger.error(`lifecycle onDestroy failed for tenant '${tenant.id}'`, { error: err?.message });
132
+ }
133
+ }
134
+ }
135
+
136
+ export {
137
+ loadLifecycles,
138
+ runBeforeBoot,
139
+ runAfterBoot,
140
+ runOnDestroy,
141
+ };
package/lib/redact.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { cfg } from "../server/config";
2
+
3
+ /**
4
+ * Keys whose value is replaced by `[redacted]` before an audit entry is stored
5
+ * (in `_audit_` **and** in the local file backup), at any depth.
6
+ *
7
+ * Override with `server.audit.redact: ['…']`, disable with `server.audit.redact: false`.
8
+ */
9
+ const DEFAULT_REDACT_KEYS = [
10
+ 'password',
11
+ 'passwd',
12
+ 'pwd',
13
+ 'token',
14
+ 'auth_token',
15
+ 'access_token',
16
+ 'refresh_token',
17
+ 'id_token',
18
+ 'authorization',
19
+ 'apikey',
20
+ 'api_key',
21
+ 'key',
22
+ 'secret',
23
+ 'cookie',
24
+ 'session_id',
25
+ 'otp',
26
+ 'pin',
27
+ ]
28
+
29
+ const REDACTED = '[redacted]'
30
+
31
+ /** Depth guard — a pathological payload must never cost more than a bounded walk */
32
+ const MAX_DEPTH = 8
33
+
34
+ // Cache initialised for the default state (`redact` unset) — a sentinel like
35
+ // `undefined` would be indistinguishable from the real, unset configuration.
36
+ let cachedConfig: unknown = undefined
37
+ let cachedKeys: Set<string> | null = new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()))
38
+
39
+ /** Lower-cased redaction keys, or `null` when redaction is disabled */
40
+ function redactKeys(): Set<string> | null {
41
+ const configured = cfg.server?.audit?.redact
42
+ if (configured !== cachedConfig) {
43
+ cachedConfig = configured
44
+ cachedKeys = configured === false
45
+ ? null
46
+ : new Set((configured ?? DEFAULT_REDACT_KEYS).map((key) => key.toLowerCase()))
47
+ }
48
+ return cachedKeys
49
+ }
50
+
51
+ /**
52
+ * Is this key sensitive? Exact match, case-insensitive, `-`/`.` normalised to `_`
53
+ * (`api-key` → `api_key`), plus the usual header prefix (`x-api-key` → `api_key`).
54
+ *
55
+ * Deliberately **not** a substring match: `key` must not match `monkey`.
56
+ */
57
+ function isSensitiveKey(name: string, keys: Set<string>): boolean {
58
+ const normalized = name.toLowerCase().replace(/[-.]/g, '_')
59
+ if (keys.has(normalized)) return true
60
+ if (normalized.startsWith('x_')) return keys.has(normalized.slice(2))
61
+ return false
62
+ }
63
+
64
+ /**
65
+ * Replace the value of every sensitive key (see `isSensitiveKey`), at any depth.
66
+ * Arrays are traversed; **non-plain objects** (`Date`, `ObjectId`, `Buffer`,
67
+ * `Map`…) are left untouched so stored types are never altered.
68
+ *
69
+ * Returns the **same reference** when nothing matched — the common case (no
70
+ * sensitive key in the payload) costs the walk but no allocation.
71
+ */
72
+ function redact(value: any, keys: Set<string>, depth = 0): any {
73
+ if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value
74
+
75
+ if (Array.isArray(value)) {
76
+ let changed = false
77
+ const out = value.map((item) => {
78
+ const next = redact(item, keys, depth + 1)
79
+ if (next !== item) changed = true
80
+ return next
81
+ })
82
+ return changed ? out : value
83
+ }
84
+
85
+ const proto = Object.getPrototypeOf(value)
86
+ if (proto !== Object.prototype && proto !== null) return value
87
+
88
+ let changed = false
89
+ const out: Record<string, any> = {}
90
+ for (const [name, item] of Object.entries(value)) {
91
+ if (isSensitiveKey(name, keys)) {
92
+ out[name] = REDACTED
93
+ changed = true
94
+ continue
95
+ }
96
+ const next = redact(item, keys, depth + 1)
97
+ if (next !== item) changed = true
98
+ out[name] = next
99
+ }
100
+ return changed ? out : value
101
+ }
102
+
103
+ /** Redact an audit payload — `input`, `result`, `meta` or `request` */
104
+ function redactPayload(value: any): any {
105
+ const keys = redactKeys()
106
+ if (!keys || value === undefined || value === null) return value
107
+ return redact(value, keys)
108
+ }
109
+
110
+ export {
111
+ DEFAULT_REDACT_KEYS,
112
+ REDACTED,
113
+ redactKeys,
114
+ isSensitiveKey,
115
+ redactPayload,
116
+ redact,
117
+ }
package/lib/workflow.ts CHANGED
@@ -28,7 +28,7 @@ async function syncWorkflows() {
28
28
  }
29
29
  }
30
30
 
31
- // Créer les indexes sur _workflows_ pour les contextes définis
31
+ // Create the indexes on _workflows_ for the defined contexts
32
32
  for (const [key, wf] of workflows) {
33
33
  const tenantId = key.split(':')[0]
34
34
  if (!wf.context || !tenantId) continue
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.39",
4
+ "version": "0.0.41",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
package/server/api.ts CHANGED
@@ -15,9 +15,12 @@ import { getTenantMiddlewares } from "../lib/middleware";
15
15
  import type { ActivityInput } from "../types/activity";
16
16
  import * as os from 'node:os';
17
17
  import { basename } from 'node:path';
18
- import { handleUpload, handleServe, handleDelete } from "../lib/files";
18
+ import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
19
+ import { getVarsDefinition } from "../database/vars";
20
+ import { getTenant } from "../database/tenant";
19
21
  const API_PREFIX = '/api/:tenant_id/:collection/:action';
20
22
  const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
23
+ const VARS_PREFIX = '/vars/:tenant_id/:action';
21
24
  const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
22
25
  const FILES_PREFIX = '/files/:tenant_id/:collection/:file';
23
26
 
@@ -346,15 +349,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
346
349
  }
347
350
 
348
351
  // ── Access control ──
349
- // Faille 7: single implementation via evaluateAccess (checks expired/forged tokens)
352
+ // Flaw 7: single implementation via evaluateAccess (checks expired/forged tokens)
350
353
  await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
351
354
 
352
355
  // ── Execute action ──
353
356
  if (col.actions?.[action]) {
354
- response = await col.actions[action]({
355
- rest, data: body?.data, error: fn.error,
356
- io, jwt: func.jwt, token: accessToken,
357
- });
357
+ // Delegated to `rest.runAction` so the invocation is audited once,
358
+ // with the same context (`rest`, `io`, `data`, `token`, …)
359
+ response = await rest.runAction(collection, action, body?.data);
358
360
  } else {
359
361
  const handler = crudHandlers[action];
360
362
  if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
@@ -382,6 +384,97 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
382
384
  }
383
385
  });
384
386
 
387
+ // ─── Vars API ───────────────────────────────────────────────────────
388
+ app.post(VARS_PREFIX, async (c) => {
389
+ let rest: InstanceType<typeof useRest> | undefined;
390
+ let tenant_id = '';
391
+ let action = '';
392
+ let ns = '';
393
+ let body: any;
394
+ const logStart = Date.now();
395
+ try {
396
+ const ContentType = c.req.header('Content-Type');
397
+ if (ContentType?.includes('application/json')) {
398
+ try { body = await c.req.json(); } catch {
399
+ throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
400
+ }
401
+ } else {
402
+ body = {};
403
+ }
404
+
405
+ const params = c.req.param() as { tenant_id: string; action: string };
406
+ tenant_id = params.tenant_id;
407
+ action = params.action;
408
+ ns = body?.ns;
409
+
410
+ if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
411
+ if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
412
+ if (!ns) throw new AppError('Variables namespace is required', { status: 400, code: 'NAMESPACE_REQUIRED' });
413
+ if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
414
+
415
+ requestCtxStorage.set('tenant_id', tenant_id);
416
+
417
+ // Only namespaces declared with `define.Vars` are reachable, and their
418
+ // `api.access` gates every action (no rules = denied).
419
+ const definition = getVarsDefinition(tenant_id, ns);
420
+ if (!definition) throw new AppError(`Variables namespace '${ns}' not found`, { status: 400, code: 'NAMESPACE_NOT_FOUND' });
421
+
422
+ rest = new useRest({ internal: false, tenant_id });
423
+ await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c);
424
+
425
+ const vars = rest.vars;
426
+ const { key, value, meta, ttl, scope, where, entries, by } = body ?? {};
427
+ let response: any;
428
+
429
+ switch (action) {
430
+ case 'set': await vars.set(ns, key, value, { meta, ttl, scope }); response = { ok: true }; break;
431
+ case 'setMany': await vars.setMany(ns, entries ?? {}, { meta, ttl, scope }); response = { ok: true }; break;
432
+ case 'get': response = { value: await vars.get(ns, key, { scope }) }; break;
433
+ case 'entry': response = { entry: await vars.entry(ns, key, { scope }) }; break;
434
+ case 'entries': response = { entries: await vars.entries(ns, { scope, where }) }; break;
435
+ case 'all': response = { vars: await vars.all(ns, { scope, where }) }; break;
436
+ case 'del': response = { ok: await vars.del(ns, key, { scope }) }; break;
437
+ case 'has': response = { exists: await vars.has(ns, key, { scope }) }; break;
438
+ case 'incr': response = { value: await vars.incr(ns, key, by, { scope, ttl }) }; break;
439
+ case 'expire': response = { ok: await vars.expire(ns, key, ttl, { scope }) }; break;
440
+ case 'clear': response = { deleted: await vars.clear(ns, { scope }) }; break;
441
+ default: throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
442
+ }
443
+
444
+ // Audit — never store `value` / `meta` (they may hold secrets).
445
+ const accessToken = getAccessToken(c);
446
+ await logActivity({
447
+ rest,
448
+ tenant_id,
449
+ action,
450
+ collection: `_vars_:${ns}`,
451
+ status: 'success',
452
+ input: { ns, key, scope, ttl },
453
+ result: ['get', 'all', 'entry', 'entries'].includes(action) ? response : undefined,
454
+ duration: Date.now() - logStart,
455
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
456
+ });
457
+ return c.json(response);
458
+ } catch (err: any) {
459
+ if (cfg?.debug) console.error(err);
460
+ if (rest && tenant_id && ns) {
461
+ const accessToken = getAccessToken(c);
462
+ await logActivity({
463
+ rest,
464
+ tenant_id,
465
+ action,
466
+ collection: `_vars_:${ns}`,
467
+ status: 'error',
468
+ input: { ns },
469
+ error: { message: err?.message, code: err?.code || 'INTERNAL_VARS_ERROR' },
470
+ duration: Date.now() - logStart,
471
+ token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
472
+ }).catch(() => {});
473
+ }
474
+ return errorResponse(c, err);
475
+ }
476
+ });
477
+
385
478
  // ─── Service API ────────────────────────────────────────────────────
386
479
  app.post(SERVICE_PREFIX, async (c) => {
387
480
  try {
@@ -415,21 +508,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
415
508
  const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
416
509
  await evaluateAccess(serviceAccess as any, action, rest, action, c);
417
510
 
418
- // Re-read accessToken for the action handler
419
- const accessToken = getAccessToken(c);
420
-
421
- const logStart = Date.now();
422
- try {
423
- const response = await serviceInstance.actions[action]({
424
- data: body?.data, error: fn.error, io, jwt: func.jwt, token: accessToken, rest,
425
- });
426
-
427
- await logActivity({ rest, tenant_id, action, collection: service, status: 'success', input: body?.data, result: response, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } });
428
- return c.json(response);
429
- } catch (err: any) {
430
- await logActivity({ rest, tenant_id, action, collection: service, status: 'error', input: body?.data, error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' }, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } }).catch(() => {});
431
- throw err;
432
- }
511
+ // No `logActivity` here: the call is delegated to `rest.runService()`, which
512
+ // audits it once (`action: 'runService'`, `internal: false`, params only)
513
+ const response = await rest.runService(service, action, body?.data);
514
+ return c.json(response);
433
515
  } catch (err: any) {
434
516
  if (cfg?.debug) console.error(err)
435
517
  return errorResponse(c, err);
@@ -457,7 +539,9 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
457
539
  throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
458
540
  }
459
541
 
460
- // Faille 11: reject oversized bodies BEFORE parseBody buffers them in memory
542
+ // Security: reject oversized bodies BEFORE parseBody buffers them in memory.
543
+ // `maxSize` doubles as the TOTAL request allowance → a multi-file upload must
544
+ // fit within it (each file is also validated individually in handleUpload).
461
545
  const contentLength = Number(c.req.header('Content-Length') ?? 0);
462
546
  const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
463
547
  // multipart overhead (boundaries/headers) ≈ 64KB max
@@ -467,21 +551,38 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
467
551
  });
468
552
  }
469
553
 
470
- const formData = await c.req.parseBody();
471
- const fileField = formData['file'] || formData['upload'];
472
- if (!fileField || !(fileField instanceof File)) {
554
+ const formData = await c.req.parseBody({ all: true }); // repeated fields → arrays; a single occurrence stays a single value
555
+
556
+ // Collect files from both accepted field names (single or multiple)
557
+ const files: File[] = [];
558
+ for (const key of ['file', 'upload']) {
559
+ const raw = formData[key];
560
+ const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
561
+ for (const value of values) {
562
+ if (value instanceof File) files.push(value);
563
+ }
564
+ }
565
+ if (!files.length) {
473
566
  throw new AppError('No file provided. Use field name "file" or "upload".', { code: 'FILE_REQUIRED', status: 400 });
474
567
  }
475
568
 
569
+ // Metadata: non-file fields. Single value → scalar (unchanged contract),
570
+ // repeated under the same name → array.
476
571
  const data: Record<string, any> = {};
477
572
  for (const [key, value] of Object.entries(formData)) {
478
- if (key !== 'file' && key !== 'upload' && !(value instanceof File)) {
479
- data[key] = value;
480
- }
573
+ if (key === 'file' || key === 'upload') continue;
574
+ const list = (Array.isArray(value) ? value : [value]) as any[];
575
+ if (list.some((v) => v instanceof File)) continue;
576
+ data[key] = list.length === 1 ? list[0] : list;
481
577
  }
482
578
 
483
- const result = await handleUpload({ collection, tenant_id, file: fileField, data });
484
- return c.json(result);
579
+ // One document per file (per-file validation: MIME, size, storage, replication).
580
+ // 1 file → single object (backward compatible), N files → array (SDK contract).
581
+ const results: FileResult[] = [];
582
+ for (const file of files) {
583
+ results.push(await handleUpload({ collection, tenant_id, file, data }));
584
+ }
585
+ return c.json(results.length === 1 ? results[0] : results);
485
586
  } catch (err: any) {
486
587
  if (cfg?.debug) console.error(err)
487
588
  return errorResponse(c, err);
@@ -504,7 +605,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
504
605
  }
505
606
  await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
506
607
 
507
- // Faille 18: validate the RAW path before basename (checks were dead code)
608
+ // Flaw 18: validate the RAW path before basename (checks were dead code)
508
609
  const rawFile = c.req.param('file') as string;
509
610
  if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
510
611
  throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
@@ -530,7 +631,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
530
631
  c.header('Content-Type', result.mimetype);
531
632
  if (result.size) c.header('Content-Length', String(result.size));
532
633
  c.header('Cache-Control', 'public, max-age=31536000, immutable');
533
- // Faille 4: SVG served as attachment (no inline execution)
634
+ // Flaw 4: SVG served as attachment (no inline execution)
534
635
  if (result.attachment) {
535
636
  c.header('Content-Disposition', `attachment; filename="${filename}"`);
536
637
  }
@@ -573,7 +674,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
573
674
  // ─── Public config ──────────────────────────────────────────────────
574
675
  app.get('/_dnax/config/:tenant_id', (c) => {
575
676
  const t = c.get('token');
576
- // Faille 6: require a VERIFIED, non-expired token (not just any provided value)
677
+ // Flaw 6: require a VERIFIED, non-expired token (not just any provided value)
577
678
  if (!t?.decoded || t?.expired || !t?.provided) {
578
679
  return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
579
680
  }