@anteros/core 0.0.32 → 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.
- package/bench-cache.ts +70 -0
- package/database/field.crypto.ts +219 -0
- package/database/mongodbadapter.ts +27 -10
- package/index.ts +0 -2
- package/package.json +1 -1
- package/server/boot.ts +17 -1
- package/server/config.ts +13 -0
- package/tests/crypto.test.ts +262 -2
- package/tests/field-encryption.test.ts +193 -0
- package/types/config.d.ts +15 -0
- package/types/field.d.ts +6 -0
- package/utils/crypto.ts +236 -62
- package/utils/index.ts +3 -0
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
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
|
-
|
|
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',
|
|
@@ -245,6 +246,21 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
245
246
|
box += `${NAME}`.gray.underline + ` (PID: ${process.pid}) — ${role}\n\n`
|
|
246
247
|
box += `Env: ${env || 'dev'}`.green.bold + '\n'
|
|
247
248
|
box += `reusePort: ${(reusePort || isWorker) ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
|
|
249
|
+
|
|
250
|
+
// DB query cache status (server.cache) — driver: memory | filesystem | redis
|
|
251
|
+
const cacheCfg = cfg.server.cache;
|
|
252
|
+
// any: @colors/colors type les styles chaînés (.red.bold) comme des fonctions Color
|
|
253
|
+
let cacheInfo: any = 'Off'.red.bold;
|
|
254
|
+
if (cacheCfg?.enabled) {
|
|
255
|
+
const driver = cacheCfg.driver ?? 'memory';
|
|
256
|
+
const detail = driver === 'filesystem'
|
|
257
|
+
? ` ${cacheCfg.directory ?? './.cache'}`
|
|
258
|
+
: driver === 'redis'
|
|
259
|
+
? ` ${cacheCfg.redis?.host ?? 'localhost'}${cacheCfg.redis?.port ? `:${cacheCfg.redis.port}` : ''}`
|
|
260
|
+
: '';
|
|
261
|
+
cacheInfo = `On`.green.bold + ` (${driver}${detail})`;
|
|
262
|
+
}
|
|
263
|
+
box += `Cache: ${cacheInfo}`.gray.bold + '\n'
|
|
248
264
|
box += `Url: http://localhost:${PORT}`.gray.bold;
|
|
249
265
|
box += '\n\n';
|
|
250
266
|
box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
|
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
|
|