@anteros/core 0.0.33 → 0.0.35

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/bench-cache.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Scratch benchmark — `bun run bench-cache.ts` (depuis packages/core)
2
+ // Vérifie que useCache fonctionne vraiment et mesure les ms (miss vs hit vs sans cache).
3
+ import { useRest } from "./database/rest";
4
+ import { formatConfig, cfg } from "./server/config";
5
+ import { syncTenants } from "./database/tenant";
6
+ import { syncCollections } from "./database/collection";
7
+
8
+ const TENANT = "bench";
9
+ const DB = "mongodb://localhost:27017/_DB_BENCH";
10
+ const N = 10000; // docs seedés
11
+
12
+ async function main() {
13
+ formatConfig({
14
+ server: { port: 4000, cache: { enabled: true, driver: "memory" } },
15
+ tenants: [{ id: TENANT, dir: "bench-tenant", database: { uri: DB } }],
16
+ });
17
+ await syncTenants();
18
+ await syncCollections();
19
+
20
+ (cfg as any).collections = [
21
+ { _tenant_: TENANT, slug: "items", fields: [{ name: "title", type: "string" }, { name: "count", type: "number" }], api: { access: { "*": true } } },
22
+ ];
23
+ const rest = new useRest({ internal: false, tenant_id: TENANT });
24
+ const col = rest.db.collection("items");
25
+
26
+ // Seed
27
+ console.log(`Seeding ${N} docs…`);
28
+ const batch = [];
29
+ for (let i = 0; i < N; i++) batch.push({ title: `item-${i % 100}`, count: i });
30
+ for (let i = 0; i < batch.length; i += 1000) await col.insertMany(batch.slice(i, i + 1000));
31
+ console.log("Seeded ✓");
32
+
33
+ const params = { $match: { count: { $gte: 1000, $lte: 9000 } }, $sort: { count: -1 }, $limit: 50 };
34
+
35
+ // 1. Sans cache — moyenne sur 100 requêtes
36
+ const t0 = performance.now();
37
+ const ITERS = 100;
38
+ for (let i = 0; i < ITERS; i++) await rest.find("items", params);
39
+ const noCacheAvg = (performance.now() - t0) / ITERS;
40
+
41
+ // 2. Avec cache — 1er appel (MISS → requête DB) puis hits
42
+ const tMiss = performance.now();
43
+ const r1 = await rest.find("items", params, { useCache: true });
44
+ const missMs = performance.now() - tMiss;
45
+ console.log(`MISS: ${r1.length} docs retournés (requête DB réelle)`);
46
+
47
+ const tHits = performance.now();
48
+ for (let i = 0; i < ITERS; i++) await rest.find("items", params, { useCache: true });
49
+ const hitAvg = (performance.now() - tHits) / ITERS;
50
+
51
+ // 3. Preuve que le hit sert BIEN depuis le cache (pas la DB) :
52
+ // on mute DIRECTEMENT en DB (bypass framework) un doc présent dans le résultat.
53
+ await col.updateMany({ count: 9000 }, { $set: { title: "MUTATED-DIRECT-DB" } });
54
+ const cached = await rest.find("items", params, { useCache: true });
55
+ const dbFresh = await rest.find("items", params);
56
+ const stale = cached.some((d: any) => d.title === "MUTATED-DIRECT-DB");
57
+ const fresh = dbFresh.some((d: any) => d.title === "MUTATED-DIRECT-DB");
58
+
59
+ console.log("\n─── RÉSULTATS (driver memory, Mongo local) ───");
60
+ console.log(`Sans cache : ${noCacheAvg.toFixed(3)} ms/requête (${ITERS} requêtes)`);
61
+ console.log(`MISS (cache): ${missMs.toFixed(3)} ms (1er appel → requête DB, résultat mis en cache)`);
62
+ console.log(`HIT (cache) : ${hitAvg.toFixed(3)} ms/requête (${ITERS} requêtes)`);
63
+ console.log(`Gain : ${(noCacheAvg / hitAvg).toFixed(0)}× plus rapide`);
64
+ console.log(`Preuve cache: cache sert ${stale ? "la VALEUR MUTÉE (bug! cache pas servi)" : "l'ANCIENNE valeur (cache servi ✓)"}, requête fraîche = ${fresh ? "nouvelle valeur (DB bien mutée ✓)" : "?"}`);
65
+
66
+ await col.drop();
67
+ process.exit(0);
68
+ }
69
+
70
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,219 @@
1
+ import { cfg } from "../server/config";
2
+ import { AppError } from "../lib/error";
3
+ import type { Collection } from "../types/collection";
4
+
5
+ /**
6
+ * Field-level encryption — `encryption: true` encrypts the whole field value;
7
+ * `encryptionOptions: { path: 'zip' }` encrypts only a sub-path inside a
8
+ * `json` (or array of json) field, e.g. `address.zip`.
9
+ *
10
+ * Uses the server encryption config (utils.crypt.resolve() — mode-driven:
11
+ * symmetric AES-256-GCM or asymmetric RSA-OAEP envelope).
12
+ *
13
+ * - Writes (insert/update): encrypted values are encrypted BEFORE storage.
14
+ * - Reads: NO automatic decryption — ciphertext is returned as stored
15
+ * (decrypt manually with utils.crypt.resolve().decrypt(value, true)).
16
+ *
17
+ * ⚠️ Encrypted values are opaque in Mongo — $match/$sort/$group on them operates
18
+ * on ciphertext (don't use them as query/order keys).
19
+ */
20
+
21
+ async function getCipher() {
22
+ const { resolve } = await import("../utils/crypto");
23
+ return resolve();
24
+ }
25
+
26
+ type PathField = { name: string; paths: string[] };
27
+
28
+ /** Fields marked `encryption: true` (whole value) */
29
+ export function encryptedFields(col: Collection | null): string[] {
30
+ return (col?.fields ?? []).filter((f) => f.encryption).map((f) => f.name);
31
+ }
32
+
33
+ /** Fields with `encryptionOptions.path` (sub-path inside json / array of json) */
34
+ export function encryptedPaths(col: Collection | null): PathField[] {
35
+ return (col?.fields ?? [])
36
+ .filter((f) => f.encryptionOptions?.path)
37
+ .map((f) => ({
38
+ name: f.name,
39
+ paths: (Array.isArray(f.encryptionOptions!.path) ? f.encryptionOptions!.path : [f.encryptionOptions!.path]) as string[],
40
+ }));
41
+ }
42
+
43
+ function requireConfig(col: Collection | null, targets: string[]) {
44
+ if (!cfg.server.encryption?.mode) {
45
+ throw new AppError(
46
+ `Encrypted field(s) [${targets.join(", ")}] require server.encryption to be configured (mode + keys)`,
47
+ { code: "ENCRYPTION_NOT_CONFIGURED", status: 500 }
48
+ );
49
+ }
50
+ }
51
+
52
+ /** Read a dot-path value ('a.b.c') */
53
+ function getPath(obj: any, path: string): any {
54
+ return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
55
+ }
56
+
57
+ /** Write a dot-path value ('a.b.c') — only if the intermediate chain exists */
58
+ function setPath(obj: Record<string, any>, path: string, value: any): void {
59
+ const parts = path.split(".");
60
+ let cur: any = obj;
61
+ for (let i = 0; i < parts.length - 1; i++) {
62
+ const next = cur?.[parts[i]!];
63
+ if (!next || typeof next !== "object") return; // intermediate missing → nothing to encrypt
64
+ cur = next;
65
+ }
66
+ cur[parts[parts.length - 1]!] = value;
67
+ }
68
+
69
+ /** Encrypt the values at `paths` inside a json value — object or array of json objects */
70
+ async function encryptAt(cipher: any, value: any, paths: string[]): Promise<any> {
71
+ if (Array.isArray(value)) {
72
+ return Promise.all(value.map((item) => encryptAt(cipher, item, paths)));
73
+ }
74
+ if (!value || typeof value !== "object") return value;
75
+ const out: Record<string, any> = { ...value };
76
+ for (const p of paths) {
77
+ const v = getPath(out, p);
78
+ if (v !== undefined && v !== null) {
79
+ setPath(out, p, await cipher.encrypt(JSON.stringify(v))); // JSON-wrap → decrypt(..., true) round-trips any value
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Encrypt the encrypted values present in a write payload (new object, input untouched) */
86
+ export async function encryptFields(col: Collection | null, data: Record<string, any> | null | undefined): Promise<any> {
87
+ const names = encryptedFields(col);
88
+ const pathFields = encryptedPaths(col);
89
+ if ((!names.length && !pathFields.length) || !data || typeof data !== "object") return data;
90
+ if (Array.isArray(data)) return Promise.all(data.map((d) => encryptFields(col, d)));
91
+
92
+ const targets = [...names, ...pathFields.map((p) => p.paths.map((path) => `${p.name}.${path}`)).flat()];
93
+ requireConfig(col, targets);
94
+
95
+ const out: Record<string, any> = { ...data };
96
+ const cipher = await getCipher();
97
+
98
+ // Whole-value fields (encryption: true)
99
+ for (const name of names) {
100
+ const v = out[name];
101
+ if (v !== undefined && v !== null) {
102
+ out[name] = await cipher.encrypt(JSON.stringify(v));
103
+ }
104
+ }
105
+ // Path fields (encryptionOptions.path) — encrypt only the sub-path(s)
106
+ for (const { name, paths } of pathFields) {
107
+ const v = out[name];
108
+ if (v !== undefined && v !== null) {
109
+ out[name] = await encryptAt(cipher, v, paths);
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /**
116
+ * Encrypt $set / $setOnInsert values for encrypted fields; reject operators that
117
+ * cannot work on ciphertext ($inc, $push, $addToSet, …). $unset/$rename/$currentDate are safe.
118
+ */
119
+ export async function encryptUpdate(col: Collection | null, update: Record<string, any> | null | undefined): Promise<any> {
120
+ const names = encryptedFields(col);
121
+ const pathFields = encryptedPaths(col);
122
+ if ((!names.length && !pathFields.length) || !update || typeof update !== "object") return update;
123
+ const nameSet = new Set(names);
124
+
125
+ // Resolve a $set key → whole-field | sub-path of a path-field | null (not encrypted)
126
+ const resolveKey = (k: string): { kind: "whole" | "path"; field: string; sub: string | null; paths?: string[] } | null => {
127
+ if (nameSet.has(k)) return { kind: "whole", field: k, sub: null };
128
+ const pf = pathFields.find((p) => k === p.name || k.startsWith(p.name + "."));
129
+ if (pf) {
130
+ const sub = k === pf.name ? null : k.slice(pf.name.length + 1);
131
+ // chemin exact, descendant ('zip.sub') ou à travers un index de array ('0.zip')
132
+ if (sub === null || pf.paths.some((p) => sub === p || sub.startsWith(p + ".") || sub.endsWith("." + p))) {
133
+ return { kind: "path", field: pf.name, sub, paths: pf.paths };
134
+ }
135
+ }
136
+ return null;
137
+ };
138
+
139
+ const out: Record<string, any> = { ...update };
140
+
141
+ for (const [op, values] of Object.entries(out)) {
142
+ if ((op === "$set" || op === "$setOnInsert") && values && typeof values === "object") {
143
+ const keys = Object.keys(values);
144
+ const hits = keys.map((k) => ({ k, hit: resolveKey(k) })).filter((x) => x.hit);
145
+ if (!hits.length) continue;
146
+ requireConfig(col, hits.map(({ hit }) => hit!.kind === "whole" ? hit!.field : hit!.sub ? `${hit!.field}.${hit!.sub}` : `${hit!.field}.${hit!.paths![0]}`));
147
+ const cipher = await getCipher();
148
+ const newValues: Record<string, any> = { ...values };
149
+ for (const { k, hit } of hits) {
150
+ const v = newValues[k];
151
+ if (v === undefined || v === null) continue;
152
+ if (hit!.kind === "whole") {
153
+ newValues[k] = await cipher.encrypt(JSON.stringify(v));
154
+ } else if (hit!.sub === null) {
155
+ // whole json replacement → encrypt the configured paths inside
156
+ newValues[k] = await encryptAt(cipher, v, hit!.paths!);
157
+ } else {
158
+ // dot-notation leaf ('address.zip') → encrypt the value directly
159
+ newValues[k] = await cipher.encrypt(JSON.stringify(v));
160
+ }
161
+ }
162
+ out[op] = newValues;
163
+ } else if (op === "$unset" || op === "$rename" || op === "$currentDate") {
164
+ // safe on encrypted fields — no value written
165
+ } else if (op.startsWith("$") && values && typeof values === "object") {
166
+ const targets = Object.keys(values).filter((k) => resolveKey(k) !== null);
167
+ if (targets.length) {
168
+ throw new AppError(
169
+ `Operator ${op} on encrypted field(s) [${targets.join(", ")}] is not supported — only $set/$setOnInsert can write encrypted fields`,
170
+ { code: "ENCRYPTED_FIELD_OPERATOR", status: 400 }
171
+ );
172
+ }
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Decrypt the encrypted values in a read result (new object; non-ciphertext kept as-is) — manual use */
179
+ export async function decryptFields(col: Collection | null, doc: any): Promise<any> {
180
+ const names = encryptedFields(col);
181
+ const pathFields = encryptedPaths(col);
182
+ if ((!names.length && !pathFields.length) || !doc || typeof doc !== "object" || !cfg.server.encryption?.mode) return doc;
183
+ const out: Record<string, any> = { ...doc };
184
+ const cipher = await getCipher();
185
+ for (const name of names) {
186
+ const v = out[name];
187
+ if (typeof v === "string") {
188
+ try {
189
+ out[name] = await cipher.decrypt(v, true);
190
+ } catch {
191
+ // not a ciphertext (legacy plaintext) → keep as-is
192
+ }
193
+ }
194
+ }
195
+ for (const { name, paths } of pathFields) {
196
+ const v = out[name];
197
+ if (v && typeof v === "object") out[name] = await decryptAt(cipher, v, paths);
198
+ }
199
+ return out;
200
+ }
201
+
202
+ async function decryptAt(cipher: any, value: any, paths: string[]): Promise<any> {
203
+ if (Array.isArray(value)) {
204
+ return Promise.all(value.map((item) => decryptAt(cipher, item, paths)));
205
+ }
206
+ if (!value || typeof value !== "object") return value;
207
+ const out: Record<string, any> = { ...value };
208
+ for (const p of paths) {
209
+ const v = getPath(out, p);
210
+ if (typeof v === "string") {
211
+ try {
212
+ setPath(out, p, await cipher.decrypt(v, true));
213
+ } catch {
214
+ // not a ciphertext → keep as-is
215
+ }
216
+ }
217
+ }
218
+ return out;
219
+ }
@@ -11,6 +11,7 @@ import { AppError, fn } from "../lib/error";
11
11
  import { createWorkflow } from "./workflow";
12
12
  import { getTenant } from "./tenant";
13
13
  import { getCollection, getCollectionKeys } from "./collection"
14
+ import { encryptFields, encryptUpdate } from "./field.crypto"
14
15
  import type { RestActions, BulkUpdateOperation, RestOptions, CursorOptions, TenantCache } from "../types/rest";
15
16
  import type Joi from "joi";
16
17
  import type { Collection } from "../types/collection";
@@ -565,11 +566,15 @@ class MongoRest {
565
566
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
566
567
  meta.data = this._validate({ collection, action: action, data: meta.data })
567
568
 
568
- await this.db.collection(collection).insertOne(func.toBson(meta.data, { col }) as any, {
569
+ // Field-level encryption: store ciphertext, keep plaintext for the response
570
+ const stored = await encryptFields(col, meta.data)
571
+ // ⚠️ toBson returns a copy — the driver's _id mutation never reaches meta.data,
572
+ // so the generated _id is taken from the InsertOneResult instead
573
+ const inserted = await this.db.collection(collection).insertOne(func.toBson(stored, { col }) as any, {
569
574
  session: this.session,
570
575
  })
571
576
 
572
- const result: T & { _id: string } = func.toJson(meta.data as T & { _id: string })
577
+ const result: T & { _id: string } = { ...func.toJson(meta.data as any), _id: (inserted.insertedId as ObjectId).toHexString() }
573
578
  meta.result = result
574
579
  if (col?.hooks?.afterOperation) {
575
580
  await col.hooks.afterOperation({ rest: this, io, action, meta })
@@ -604,11 +609,17 @@ class MongoRest {
604
609
  // Validate AFTER hooks (they may fill required fields or apply Joi defaults)
605
610
  meta.data = this._validate({ collection, action: action, data: meta.data })
606
611
 
607
- await this.db.collection(collection).insertMany(func.toBson(meta.data, { col }) as any, {
612
+ // Field-level encryption: store ciphertext, keep plaintext for the response
613
+ const stored = await encryptFields(col, meta.data)
614
+ const inserted = await this.db.collection(collection).insertMany(func.toBson(stored, { col }) as any, {
608
615
  session: this.session,
609
616
  })
610
617
 
611
- const result: (T & { _id: string })[] = func.toJson(meta.data as (T & { _id: string })[])
618
+ const json = func.toJson(meta.data) as any[]
619
+ const result: (T & { _id: string })[] = json.map((d, i) => ({
620
+ _id: (inserted.insertedIds[i] as ObjectId | undefined)?.toHexString() ?? d._id,
621
+ ...d,
622
+ }))
612
623
  meta.result = result
613
624
 
614
625
  if (col.hooks?.afterOperation) {
@@ -633,6 +644,8 @@ class MongoRest {
633
644
 
634
645
  update = await func.buildInput(update)
635
646
  this._validate({ collection, action: action, update: update })
647
+ // Field-level encryption: encrypt $set/$setOnInsert values for encrypted fields
648
+ update = await encryptUpdate(col, update)
636
649
  update = func.toBson(update, { col })
637
650
 
638
651
  const meta: any = { action, collection, update, id: _id }
@@ -697,6 +710,8 @@ class MongoRest {
697
710
 
698
711
  this._validate({ collection, action: action, update: update.$set })
699
712
  this._validate({ collection, action: action, update: update.$setOnInsert })
713
+ // Field-level encryption: encrypt $set/$setOnInsert values for encrypted fields
714
+ update = await encryptUpdate(col, update)
700
715
  update = func.toBson(update, { col })
701
716
  filter = func.toBson(filter, { col })
702
717
 
@@ -738,6 +753,8 @@ class MongoRest {
738
753
  }
739
754
  update = await func.buildInput(update)
740
755
  this._validate({ collection, action: action, update: update })
756
+ // Field-level encryption: encrypt $set/$setOnInsert values for encrypted fields
757
+ update = await encryptUpdate(col, update)
741
758
  update = func.toBson(update, { col })
742
759
 
743
760
  const meta: any = { action, collection, update, ids: _ids }
@@ -829,7 +846,7 @@ class MongoRest {
829
846
 
830
847
  if ('insertOne' in operation) {
831
848
  operation.insertOne.document = func.toBson(
832
- await func.buildInput(operation.insertOne.document, { action: 'insertOne', col }),
849
+ await encryptFields(col, await func.buildInput(operation.insertOne.document, { action: 'insertOne', col })),
833
850
  { col },
834
851
  )
835
852
  }
@@ -842,7 +859,7 @@ class MongoRest {
842
859
  if (updateOneDoc.$set) {
843
860
  updateOneDoc.$set = await func.buildInput(updateOneDoc.$set, { action: 'updateOne', col })
844
861
  }
845
- operation.updateOne.update = func.toBson(operation.updateOne.update, { col })
862
+ operation.updateOne.update = func.toBson(await encryptUpdate(col, operation.updateOne.update), { col })
846
863
  }
847
864
  if ('updateMany' in operation) {
848
865
  operation.updateMany.filter = func.toBson(
@@ -853,11 +870,11 @@ class MongoRest {
853
870
  if (updateManyDoc.$set) {
854
871
  updateManyDoc.$set = await func.buildInput(updateManyDoc.$set, { action: 'updateMany', col })
855
872
  }
856
- operation.updateMany.update = func.toBson(operation.updateMany.update, { col })
873
+ operation.updateMany.update = func.toBson(await encryptUpdate(col, operation.updateMany.update), { col })
857
874
  }
858
875
  if ('replaceOne' in operation) {
859
876
  operation.replaceOne.replacement = func.toBson(
860
- await func.buildInput(operation.replaceOne.replacement, { action: 'replaceOne', col }),
877
+ await encryptFields(col, await func.buildInput(operation.replaceOne.replacement, { action: 'replaceOne', col })),
861
878
  { col },
862
879
  )
863
880
  }
@@ -898,14 +915,14 @@ class MongoRest {
898
915
  if (update.$set) {
899
916
  update.$set = await func.buildInput(update.$set, { action: 'updateOne', col })
900
917
  }
901
- operation.updateOne.update = func.toBson(operation.updateOne.update, { col })
918
+ operation.updateOne.update = func.toBson(await encryptUpdate(col, operation.updateOne.update), { col })
902
919
  }
903
920
  if ('updateMany' in operation) {
904
921
  const update = operation.updateMany.update as UpdateFilter<Document>
905
922
  if (update.$set) {
906
923
  update.$set = await func.buildInput(update.$set, { action: 'updateMany', col })
907
924
  }
908
- operation.updateMany.update = func.toBson(operation.updateMany.update, { col })
925
+ operation.updateMany.update = func.toBson(await encryptUpdate(col, operation.updateMany.update), { col })
909
926
  }
910
927
  }
911
928
 
package/index.ts CHANGED
@@ -6,7 +6,6 @@ import { useRest } from "./database/rest";
6
6
  import { AppError } from "./lib/error";
7
7
  import * as v from "joi";
8
8
  import utils from "./utils";
9
- import * as crypto from "./utils/crypto";
10
9
 
11
10
 
12
11
 
@@ -31,6 +30,5 @@ export {
31
30
  v,
32
31
  utils,
33
32
  cache,
34
- crypto,
35
33
  logger,
36
34
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.33",
4
+ "version": "0.0.35",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
package/server/boot.ts CHANGED
@@ -38,7 +38,8 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
38
38
 
39
39
  // Logging: configure from cfg.server.logging (console + file, e.g. logs/anteros.log)
40
40
  logger.configure(cfg.server.logging);
41
- logger.info('Starting Anteros server', {
41
+ // File-only: the boxen banner already shows name / PID / URL / env on the console
42
+ logger.file('Starting Anteros server', {
42
43
  name: cfg.server.name ?? process.env.APP_NAME ?? 'SERVER',
43
44
  port: cfg.server.port ?? 4000,
44
45
  env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
package/server/config.ts CHANGED
@@ -21,6 +21,19 @@ function formatConfig(config: ServerConfig) { // format the config
21
21
  }
22
22
  cfg.version = config.version;
23
23
  cfg.tenants = config.tenants ?? [];
24
+
25
+ // Encryption mode validation — fail fast at boot: the declared mode
26
+ // must have its key configured (symmetric → secret, asymmetric → privateKey)
27
+ const enc = cfg.server.encryption;
28
+ if (enc) {
29
+ if (enc.mode === 'symmetric' && !enc.secret && !Bun.env.APP_SECRET) {
30
+ throw new Error(`server.encryption: mode 'symmetric' requires a secret — set encryption.secret or APP_SECRET env`);
31
+ }
32
+ if (enc.mode === 'asymmetric' && !enc.privateKey) {
33
+ throw new Error(`server.encryption: mode 'asymmetric' requires a privateKey — set encryption.privateKey (JWK or JSON string)`);
34
+ }
35
+ }
36
+
24
37
  return cfg;
25
38
  }
26
39