@anteros/core 0.0.33 → 0.0.34

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.
@@ -1,5 +1,7 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { useSymCrypt, useAsymCrypt } from "../utils/crypto";
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import { useSymCrypt, useAsymCrypt, resolve } from "../utils/crypto";
3
+ import { cfg, formatConfig } from "../server/config";
4
+ import utils from "../utils";
3
5
 
4
6
  describe("crypto — symmetric AES-256-GCM", () => {
5
7
  it("round-trips a string", async () => {
@@ -57,6 +59,191 @@ describe("crypto — symmetric AES-256-GCM", () => {
57
59
  const c = useSymCrypt({ secret: "s" });
58
60
  await expect(c.decrypt("YWJjZA==")).rejects.toThrow();
59
61
  });
62
+
63
+ it("decrypt(cipher, true, fields) decrypts the listed sub-fields inside the JSON", async () => {
64
+ const c = useSymCrypt({ secret: "s" });
65
+ // json contenant des sous-champs chiffrés (convention field-level : JSON.stringify)
66
+ const encZip = await c.encrypt(JSON.stringify("75000"));
67
+ const encPhone = await c.encrypt(JSON.stringify("0600000000"));
68
+ const container = await c.encrypt({ city: "Paris", zip: encZip, contact: { phone: encPhone } });
69
+
70
+ const dec = await c.decrypt<any>(container, true, ["zip", "contact.phone"]);
71
+ expect(dec.city).toBe("Paris");
72
+ expect(dec.zip).toBe("75000");
73
+ expect(dec.contact.phone).toBe("0600000000");
74
+
75
+ // sans les champs → sous-champs toujours chiffrés
76
+ const raw = await c.decrypt<any>(container, true);
77
+ expect(raw.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
78
+ expect(raw.city).toBe("Paris");
79
+ });
80
+
81
+ it("decrypt(jsonObject, true, fields) decrypts sub-fields of a plaintext json container", async () => {
82
+ const c = useSymCrypt({ secret: "s" });
83
+ const encZip = await c.encrypt(JSON.stringify("69001"));
84
+ const json = { street: "Rue X", zip: encZip, tags: [{ code: encZip }] };
85
+
86
+ const dec = await c.decrypt<any>(json, true, ["zip", "tags.0.code"]);
87
+ expect(dec.street).toBe("Rue X");
88
+ expect(dec.zip).toBe("69001");
89
+ expect(dec.tags[0].code).toBe("69001");
90
+ });
91
+
92
+ it("decrypt(..., fields) keeps non-ciphertext values as-is", async () => {
93
+ const c = useSymCrypt({ secret: "s" });
94
+ const json = { city: "Paris", note: "plain text", zip: await c.encrypt(JSON.stringify("75000")) };
95
+ const dec = await c.decrypt<any>(json, true, ["zip", "note", "missing"]);
96
+ expect(dec.city).toBe("Paris");
97
+ expect(dec.note).toBe("plain text"); // pas un ciphertext → inchangé
98
+ expect(dec.zip).toBe("75000");
99
+ });
100
+
101
+ it("decrypt handles an ARRAY of json objects with encrypted fields", async () => {
102
+ const c = useSymCrypt({ secret: "s" });
103
+ const encZip = await c.encrypt(JSON.stringify("69001"));
104
+ const encOther = await c.encrypt(JSON.stringify("69002"));
105
+
106
+ // array conteneur direct (ex: doc.addresses renvoyé par une requête)
107
+ const arr = [
108
+ { city: "Lyon", zip: encZip },
109
+ { city: "Villeurbanne", zip: encOther },
110
+ ];
111
+ const dec = await c.decrypt<any>(arr, true, ["zip"]);
112
+ expect(dec[0].zip).toBe("69001");
113
+ expect(dec[1].zip).toBe("69002");
114
+ expect(dec[0].city).toBe("Lyon"); // non-chiffré conservé
115
+ expect(dec).not.toBe(arr); // copie, pas de mutation
116
+ expect(arr[0]!.zip).toMatch(/^[A-Za-z0-9+/=]+$/); // input intact
117
+
118
+ // ciphertext dont le plaintext est un array de json
119
+ const encContainer = await c.encrypt([{ zip: encZip }, { zip: encOther }]);
120
+ const dec2 = await c.decrypt<any>(encContainer, true, ["zip"]);
121
+ expect(dec2[0].zip).toBe("69001");
122
+ expect(dec2[1].zip).toBe("69002");
123
+ });
124
+ });
125
+
126
+ describe("crypto — key rotation (versioned secrets)", () => {
127
+ it("decrypts old ciphertexts after rotating the secret", async () => {
128
+ const old = useSymCrypt({ secret: "v1-secret" }); // version 1 par défaut
129
+ const enc = await old.encrypt({ apiKey: "sk-rot" });
130
+
131
+ // Rotation : nouvelle clé active v2, l'ancienne conservée en decrypt-only
132
+ const rotated = useSymCrypt({ secret: "v2-secret", version: 2, previousSecrets: { 1: "v1-secret" } });
133
+ const dec = await rotated.decrypt<{ apiKey: string }>(enc, true);
134
+ expect(dec.apiKey).toBe("sk-rot");
135
+ });
136
+
137
+ it("tags new ciphertexts with the active version", async () => {
138
+ const c = useSymCrypt({ secret: "s", version: 2 });
139
+ const enc = await c.encrypt("data");
140
+ const buf = Buffer.from(enc, "base64");
141
+ expect(buf[0]).toBe(2);
142
+ // round-trip avec la même instance
143
+ const dec = await c.decrypt(enc);
144
+ expect(dec).toBe("data");
145
+ });
146
+
147
+ it("still decrypts legacy ciphertexts (no version byte)", async () => {
148
+ const c = useSymCrypt({ secret: "s" });
149
+ const enc = await c.encrypt("legacy");
150
+ // Simule un ciphertext produit AVANT la rotation (format sans octet de version)
151
+ const legacy = Buffer.from(Buffer.from(enc, "base64").subarray(1)).toString("base64");
152
+ const dec = await c.decrypt(legacy);
153
+ expect(dec).toBe("legacy");
154
+ });
155
+
156
+ it("the old secret cannot decrypt data encrypted with the new key", async () => {
157
+ const c = useSymCrypt({ secret: "v2", version: 2, previousSecrets: { 1: "v1" } });
158
+ const enc = await c.encrypt("new-data");
159
+ const old = useSymCrypt({ secret: "v1" });
160
+ await expect(old.decrypt(enc)).rejects.toThrow();
161
+ });
162
+
163
+ it("binds ciphertexts to the config default AAD when none is passed", async () => {
164
+ const c = useSymCrypt({ secret: "s", aad: "myapp:prod" });
165
+ const enc = await c.encrypt({ apiKey: "sk-aad" });
166
+
167
+ // même instance → OK (défaut appliqué au decrypt aussi)
168
+ const dec = await c.decrypt<{ apiKey: string }>(enc, true);
169
+ expect(dec.apiKey).toBe("sk-aad");
170
+
171
+ // sans l'AAD config → échec (tag invalide)
172
+ const other = useSymCrypt({ secret: "s" });
173
+ await expect(other.decrypt(enc, true)).rejects.toThrow();
174
+ });
175
+
176
+ it("explicit AAD wins over the config default", async () => {
177
+ const c = useSymCrypt({ secret: "s", aad: "default:ctx" });
178
+ const enc = await c.encrypt("x", "explicit:ctx");
179
+ const dec = await c.decrypt(enc, false, "explicit:ctx");
180
+ expect(dec).toBe("x");
181
+ // le défaut ne déchiffre pas le chiffré lié à l'explicite
182
+ await expect(c.decrypt(enc, false, "default:ctx")).rejects.toThrow();
183
+ });
184
+
185
+ it("legacy ciphertexts (no AAD) still decrypt after a default AAD is configured", async () => {
186
+ const legacy = useSymCrypt({ secret: "s" }); // chiffré SANS AAD (ancien comportement)
187
+ const enc = await legacy.encrypt("old-data");
188
+
189
+ const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config avec AAD par défaut
190
+ const dec = await c.decrypt(enc);
191
+ expect(dec).toBe("old-data");
192
+ });
193
+ });
194
+
195
+ describe("crypto — encryption mode (server.encryption.mode)", () => {
196
+ afterAll(() => {
197
+ delete (cfg.server as any).encryption;
198
+ formatConfig({ server: { port: 4000 }, tenants: [] });
199
+ });
200
+
201
+ it("boot validation: symmetric mode requires a secret", () => {
202
+ delete (cfg.server as any).encryption;
203
+ expect(() => formatConfig({
204
+ server: { port: 4000, encryption: { mode: "symmetric" } },
205
+ tenants: [],
206
+ })).toThrow(/symmetric.*requires a secret/);
207
+ });
208
+
209
+ it("boot validation: asymmetric mode requires a privateKey", () => {
210
+ delete (cfg.server as any).encryption;
211
+ expect(() => formatConfig({
212
+ server: { port: 4000, encryption: { mode: "asymmetric" } },
213
+ tenants: [],
214
+ })).toThrow(/asymmetric.*requires a privateKey/);
215
+ });
216
+
217
+ it("useSymCrypt() without explicit secret rejects an asymmetric mode", () => {
218
+ delete (cfg.server as any).encryption;
219
+ formatConfig({ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: {} as any } }, tenants: [] });
220
+ expect(() => useSymCrypt()).toThrow(/mode is 'asymmetric'/);
221
+ });
222
+
223
+ it("resolve() returns the instance matching the configured mode", async () => {
224
+ delete (cfg.server as any).encryption;
225
+ formatConfig({ server: { port: 4000 }, tenants: [] });
226
+ const priv = await useAsymCrypt().exportPrivateKey(); // pair fraîche, pas de mode config
227
+
228
+ delete (cfg.server as any).encryption;
229
+ formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg" } }, tenants: [] });
230
+ const sym = resolve();
231
+ const enc = await sym.encrypt({ a: 1 });
232
+ expect((await sym.decrypt<{ a: number }>(enc, true)).a).toBe(1);
233
+
234
+ delete (cfg.server as any).encryption;
235
+ formatConfig({ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: priv } }, tenants: [] });
236
+ const asym = resolve();
237
+ const enc2 = await asym.encrypt("hi");
238
+ const dec2 = await asym.decrypt(enc2);
239
+ expect(dec2).toBe("hi");
240
+ });
241
+
242
+ it("resolve() throws when mode is missing", () => {
243
+ delete (cfg.server as any).encryption;
244
+ formatConfig({ server: { port: 4000 }, tenants: [] });
245
+ expect(() => resolve()).toThrow(/mode is required/);
246
+ });
60
247
  });
61
248
 
62
249
  describe("crypto — asymmetric RSA-OAEP envelope", () => {
@@ -98,4 +285,77 @@ describe("crypto — asymmetric RSA-OAEP envelope", () => {
98
285
  // Missing context → rejected
99
286
  await expect(a.decrypt(enc, true)).rejects.toThrow();
100
287
  });
288
+
289
+ it("decrypt(cipher, true, fields) decrypts listed sub-fields (asym)", async () => {
290
+ const a = useAsymCrypt();
291
+ const encZip = await a.encrypt(JSON.stringify("75000"));
292
+ const container = await a.encrypt({ city: "Paris", zip: encZip });
293
+
294
+ const dec = await a.decrypt<any>(container, true, ["zip"]);
295
+ expect(dec.city).toBe("Paris");
296
+ expect(dec.zip).toBe("75000");
297
+ });
298
+ });
299
+
300
+ describe("crypto — public surface", () => {
301
+ it("is exposed via utils.crypt (not a top-level crypto export)", async () => {
302
+ expect(typeof utils.crypt.useSymCrypt).toBe("function");
303
+ expect(typeof utils.crypt.useAsymCrypt).toBe("function");
304
+ expect(typeof utils.crypt.resolve).toBe("function");
305
+ const top: any = await import("../index");
306
+ expect(top.crypto).toBeUndefined();
307
+ });
308
+ });
309
+
310
+ describe("crypto — server config defaults (server.encryption)", () => {
311
+ afterAll(() => {
312
+ delete (cfg.server as any).encryption;
313
+ formatConfig({ server: { port: 4000 }, tenants: [] });
314
+ });
315
+
316
+ it("useSymCrypt() without args uses server.encryption.secret", async () => {
317
+ formatConfig({
318
+ server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg-secret" } },
319
+ tenants: [],
320
+ });
321
+ const c = useSymCrypt();
322
+ const enc = await c.encrypt({ apiKey: "sk-x" });
323
+ const dec = await c.decrypt<{ apiKey: string }>(enc, true);
324
+ expect(dec.apiKey).toBe("sk-x");
325
+ });
326
+
327
+ it("useSymCrypt() throws a clear error when no secret is available", () => {
328
+ delete (cfg.server as any).encryption; // formatConfig merges — reset explicit
329
+ formatConfig({ server: { port: 4000 }, tenants: [] });
330
+ delete Bun.env.APP_SECRET;
331
+ expect(() => useSymCrypt()).toThrow(/missing secret/);
332
+ });
333
+
334
+ it("useAsymCrypt() without args uses server.encryption.privateKey (JWK)", async () => {
335
+ const gen = useAsymCrypt();
336
+ const priv = await gen.exportPrivateKey();
337
+
338
+ formatConfig({
339
+ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: priv } },
340
+ tenants: [],
341
+ });
342
+ const enc = await gen.encrypt("cfg-asym");
343
+ const c = useAsymCrypt(); // pas de privateKey → prend cfg
344
+ const dec = await c.decrypt(enc);
345
+ expect(dec).toBe("cfg-asym");
346
+ });
347
+
348
+ it("useAsymCrypt() accepts privateKey as a JSON string", async () => {
349
+ const gen = useAsymCrypt();
350
+ const priv = await gen.exportPrivateKey();
351
+
352
+ formatConfig({
353
+ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: JSON.stringify(priv) } },
354
+ tenants: [],
355
+ });
356
+ const enc = await gen.encrypt("json-key");
357
+ const c = useAsymCrypt();
358
+ const dec = await c.decrypt(enc);
359
+ expect(dec).toBe("json-key");
360
+ });
101
361
  });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
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
+ import { resolve } from "../utils/crypto";
8
+
9
+ const TENANT = "enc-test";
10
+ const DB = "mongodb://localhost:27017/_DB_ENC_TEST";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+
14
+ function encCollection(fields: any[]) {
15
+ return { _tenant_: TENANT, slug: "patients", fields, api: { access: { "*": true } } };
16
+ }
17
+
18
+ beforeAll(async () => {
19
+ formatConfig({
20
+ server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } },
21
+ tenants: [{ id: TENANT, dir: "enc-tenant", database: { uri: DB } }],
22
+ });
23
+ await syncTenants();
24
+ await syncCollections();
25
+ (cfg as any).collections = [encCollection([
26
+ { name: "name", type: "string" },
27
+ { name: "ssn", type: "string", encryption: true }, // champ chiffré
28
+ { name: "profile", type: "json", encryption: true }, // objet chiffré
29
+ { name: "age", type: "number" },
30
+ { name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json : seul address.zip est chiffré
31
+ { name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // plusieurs chemins
32
+ { name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array de json
33
+ ])];
34
+ rest = new useRest({ internal: false, tenant_id: TENANT });
35
+ });
36
+
37
+ afterAll(async () => {
38
+ delete (cfg.server as any).encryption;
39
+ formatConfig({ server: { port: 4000 }, tenants: [] });
40
+ try { await rest.db.collection("patients").drop(); } catch {}
41
+ });
42
+
43
+ describe("field-level encryption", () => {
44
+ it("insert stores ciphertext in Mongo but returns plaintext", async () => {
45
+ const doc: any = await rest.insertOne("patients", { name: "John", ssn: "123-45-6789", profile: { city: "Paris", zip: 75000 }, age: 42 });
46
+
47
+ // réponse API = plaintext
48
+ expect(doc.ssn).toBe("123-45-6789");
49
+ expect(doc.profile).toEqual({ city: "Paris", zip: 75000 });
50
+
51
+ // en base = ciphertext (version byte + IV + data)
52
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
53
+ expect(raw).not.toBeNull();
54
+ expect(raw!.ssn).not.toBe("123-45-6789");
55
+ expect(typeof raw!.ssn).toBe("string");
56
+ expect(raw!.ssn).toMatch(/^[A-Za-z0-9+/=]+$/); // base64
57
+ expect(raw!.profile).not.toEqual({ city: "Paris" });
58
+ // champ non chiffré intact
59
+ expect(raw!.name).toBe("John");
60
+ expect(raw!.age).toBe(42);
61
+ });
62
+
63
+ it("find / findOne return ciphertext (no decrypt on output)", async () => {
64
+ const list: any = await rest.find("patients", { $match: { name: "John" } });
65
+ // lecture = ciphertext (jamais décrypté automatiquement)
66
+ expect(list[0].ssn).not.toBe("123-45-6789");
67
+ expect(list[0].ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
68
+ expect(list[0].profile).not.toEqual({ city: "Paris", zip: 75000 });
69
+ // champs non chiffrés intacts
70
+ expect(list[0].name).toBe("John");
71
+
72
+ const one: any = await rest.findOne("patients", list[0]._id);
73
+ expect(one.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
74
+
75
+ // décryptage MANUEL explicite (utils.crypt.resolve())
76
+ const cipher = resolve();
77
+ const dec = await cipher.decrypt<string>(one.ssn, true);
78
+ expect(dec).toBe("123-45-6789");
79
+ });
80
+
81
+ it("update via $set encrypts the new value (returns ciphertext)", async () => {
82
+ const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
83
+ const updated: any = await rest.updateOne("patients", doc._id, { $set: { ssn: "987-65-4321" } });
84
+
85
+ // retour = ciphertext (plus de décryptage automatique)
86
+ expect(updated.ssn).not.toBe("987-65-4321");
87
+ expect(updated.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
88
+ // base = nouveau ciphertext
89
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
90
+ expect(raw).not.toBeNull();
91
+ expect(raw!.ssn).not.toBe("987-65-4321");
92
+ });
93
+
94
+ it("insertMany encrypts every doc", async () => {
95
+ await rest.insertMany("patients", [
96
+ { name: "A", ssn: "111-11-1111" },
97
+ { name: "B", ssn: "222-22-2222" },
98
+ ]);
99
+ const raw = await rest.db.collection("patients").find({ name: { $in: ["A", "B"] } }).toArray();
100
+ for (const r of raw) expect(r.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
101
+ const list: any = await rest.find("patients", { $match: { name: { $in: ["A", "B"] } } });
102
+ // lecture = ciphertext, décrypté manuellement
103
+ for (const d of list) expect(d.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
104
+ });
105
+
106
+ it("rejects operators that cannot work on ciphertext ($inc on an encrypted field)", async () => {
107
+ const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
108
+ await expect(
109
+ rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc sur age (non chiffré) OK
110
+ ).resolves.toBeTruthy();
111
+ await expect(
112
+ rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc sur champ chiffré → rejeté
113
+ ).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
114
+ });
115
+
116
+ it("throws ENCRYPTION_NOT_CONFIGURED when server.encryption is missing", async () => {
117
+ delete (cfg.server as any).encryption;
118
+ formatConfig({ server: { port: 4000 }, tenants: [] });
119
+ await expect(rest.insertOne("patients", { name: "NoCfg", ssn: "000-00-0000" }))
120
+ .rejects.toThrow(/ENCRYPTION_NOT_CONFIGURED|server\.encryption/);
121
+
122
+ // restore config pour les tests suivants
123
+ formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } }, tenants: [] });
124
+ });
125
+ });
126
+
127
+ describe("field-level encryption — encryptionOptions.path (json sub-paths)", () => {
128
+ it("encrypts only the configured sub-path of a json field", async () => {
129
+ const doc: any = await rest.insertOne("patients", {
130
+ name: "Alice",
131
+ address: { street: "Rue X", city: "Paris", zip: "75000" },
132
+ });
133
+ // réponse = plaintext partout
134
+ expect(doc.address.zip).toBe("75000");
135
+ expect(doc.address.city).toBe("Paris");
136
+
137
+ // en base : seul zip est ciphertext, le reste est en clair
138
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
139
+ expect(raw!.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
140
+ expect(raw!.address.city).toBe("Paris");
141
+ expect(raw!.address.street).toBe("Rue X");
142
+
143
+ // lecture rest = ciphertext au path, plaintext ailleurs
144
+ const list: any = await rest.find("patients", { $match: { name: "Alice" } });
145
+ expect(list[0].address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
146
+ expect(list[0].address.city).toBe("Paris");
147
+
148
+ // décryptage manuel
149
+ const cipher = resolve();
150
+ const zip = await cipher.decrypt<string>(list[0].address.zip, true);
151
+ expect(zip).toBe("75000");
152
+ });
153
+
154
+ it("supports multiple paths and array-of-json", async () => {
155
+ const doc: any = await rest.insertOne("patients", {
156
+ name: "Bob",
157
+ contacts: { phone: "0600000000", email: "bob@x.io", note: "public" },
158
+ addresses: [{ zip: "69001" }, { zip: "69002", city: "Lyon" }],
159
+ });
160
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
161
+ // array de json : chaque élément chiffré sur son zip
162
+ expect(raw!.addresses[0].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
163
+ expect(raw!.addresses[1].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
164
+ expect(raw!.addresses[1].city).toBe("Lyon"); // non-chiffré intact
165
+ // multiple paths
166
+ expect(raw!.contacts.phone).toMatch(/^[A-Za-z0-9+/=]+$/);
167
+ expect(raw!.contacts.email).toMatch(/^[A-Za-z0-9+/=]+$/);
168
+ expect(raw!.contacts.note).toBe("public");
169
+ });
170
+
171
+ it("encrypts the path on $set replacement and dot-notation $set", async () => {
172
+ const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Alice" } }))[0]!._id);
173
+
174
+ // $set du json complet → zip re-chiffré dans le remplacement
175
+ const up1: any = await rest.updateOne("patients", doc._id, { $set: { address: { city: "Nice", zip: "06000" } } });
176
+ expect(up1.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
177
+ expect(up1.address.city).toBe("Nice");
178
+
179
+ // dot-notation $set sur le path exact → valeur chiffrée
180
+ const up2: any = await rest.updateOne("patients", doc._id, { $set: { "address.zip": "06100" } });
181
+ expect(up2.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
182
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
183
+ const cipher = resolve();
184
+ expect(await cipher.decrypt<string>(raw!.address.zip, true)).toBe("06100");
185
+ });
186
+
187
+ it("rejects $inc on an encrypted sub-path", async () => {
188
+ const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Bob" } }))[0]!._id);
189
+ await expect(
190
+ rest.updateOne("patients", doc._id, { $inc: { "addresses.0.zip": 1 } as any })
191
+ ).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
192
+ });
193
+ });
package/types/config.d.ts CHANGED
@@ -95,6 +95,21 @@ export type ServerConfig = {
95
95
  /** Max `_id` tags per cached entry — large results are tagged up to this count (default: 50000; each tag costs ~0.18µs per cache hit) */
96
96
  maxTags?: number;
97
97
  };
98
+ /** Encryption keys — defaults used by utils.crypt.useSymCrypt() / utils.crypt.useAsymCrypt() */
99
+ encryption?: {
100
+ /** REQUIRED — encryption mode: 'symmetric' (AES-256-GCM) or 'asymmetric' (RSA-OAEP envelope) */
101
+ mode: 'symmetric' | 'asymmetric';
102
+ /** Symmetric AES-256-GCM secret (useSymCrypt) — required when mode is 'symmetric'. Falls back to env APP_SECRET */
103
+ secret?: string;
104
+ /** Version of the active secret — written into new ciphertexts (default: 1) */
105
+ version?: number;
106
+ /** Older secrets for decrypt-only — key rotation, e.g. { 1: 'v1-secret' } */
107
+ previousSecrets?: Record<number, string>;
108
+ /** Default AAD context — bound to every ciphertext when no explicit AAD is passed. Must stay stable (changing it makes old data undecryptable) */
109
+ aad?: string;
110
+ /** Asymmetric RSA-OAEP private key JWK for decryption (useAsymCrypt) — required when mode is 'asymmetric'. Object or JSON string */
111
+ privateKey?: JsonWebKey | string;
112
+ };
98
113
  }
99
114
  tenants: Tenant[];
100
115
  }
package/types/field.d.ts CHANGED
@@ -53,6 +53,12 @@ export type Field = {
53
53
  enumOptions?: enumOptions;
54
54
  slugOptions?: SlugOptions;
55
55
  unique?: boolean | 1 | 0; // Mongo-style: true/1 → unique index, false/0 → non-unique
56
+ /** Encrypt this field's values at rest (uses server.encryption + the configured mode) */
57
+ encryption?: boolean;
58
+ /** Encrypt sub-path(s) inside a json / array-of-json field (dot notation) — e.g. { path: 'zip' } on field 'address' encrypts address.zip */
59
+ encryptionOptions?: {
60
+ path?: string | string[];
61
+ };
56
62
  nullable?: boolean;
57
63
  empty?: boolean;
58
64
  defaultValue?: number | string | boolean | object | array<any>;