@frogfish/k2db 3.0.1 → 3.0.2
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/README.md +351 -13
- package/db.d.ts +133 -22
- package/db.js +652 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Deployment tips (Nomad, Lambda, etc.)
|
|
|
35
35
|
export const handler = async (event) => {
|
|
36
36
|
ready = ready || db.init();
|
|
37
37
|
await ready; // reused across warm invocations
|
|
38
|
-
const res = await db.find("hello", {
|
|
38
|
+
const res = await db.find("hello", {}, {}, 0, 10, event.userId);
|
|
39
39
|
return { statusCode: 200, body: JSON.stringify(res) };
|
|
40
40
|
};
|
|
41
41
|
```
|
|
@@ -100,7 +100,8 @@ Ownership (`_owner`)
|
|
|
100
100
|
- Purpose: `_owner` is not a tenant ID nor a technical auth scope. It’s a required, opinionated piece of metadata that records who a document belongs to (the data subject or system principal that created/owns it).
|
|
101
101
|
- Why it matters: Enables clear data lineage and supports privacy/jurisdiction workflows (GDPR/DSAR: “export all my data”, “delete my data”), audits, and stewardship.
|
|
102
102
|
- Typical values: a user’s UUID when a signed-in human creates the record; for automated/system operations use a stable identifier like `"system"`, `"service:mailer"`, or `"migration:2024-09-01"`.
|
|
103
|
-
- Not
|
|
103
|
+
- Not authentication: k2db does not authenticate callers. Your API/service still decides *who* the caller is and whether they are allowed to act.
|
|
104
|
+
- Optional enforcement: k2db can enforce owner scoping when you provide a per-call scope (see “Scope”), and can require scope on all calls when `ownershipMode: "strict"` is enabled.
|
|
104
105
|
- Multi-tenant setups: If you have tenants, keep a distinct `tenantId` (or similar) alongside `_owner`. `_owner` continues to model “who owns this record” rather than “which tenant it belongs to”.
|
|
105
106
|
|
|
106
107
|
Config
|
|
@@ -124,6 +125,343 @@ await db.init();
|
|
|
124
125
|
await db.ensureIndexes("myCollection");
|
|
125
126
|
```
|
|
126
127
|
|
|
128
|
+
### Scope config
|
|
129
|
+
|
|
130
|
+
- `ownershipMode?: "lax" | "strict"` (default: `"lax"`)
|
|
131
|
+
- `"lax"` (default): Passing a scope is optional. If you provide a scope, k2db enforces it as an owner filter; if omitted, behavior is unchanged from historical (no owner enforcement, but still enforces soft-delete).
|
|
132
|
+
- `"strict"`: Scope is *required* on all scopified methods (see “Scope” below). If you omit scope, k2db throws an error. This helps prevent accidental missing owner filters.
|
|
133
|
+
|
|
134
|
+
Example config with strict mode:
|
|
135
|
+
```ts
|
|
136
|
+
const db = new K2DB({
|
|
137
|
+
name: "mydb",
|
|
138
|
+
hosts: [{ host: "cluster0.example.mongodb.net" }],
|
|
139
|
+
user: process.env.DB_USER,
|
|
140
|
+
password: process.env.DB_PASS,
|
|
141
|
+
ownershipMode: "strict",
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Aggregation config
|
|
146
|
+
|
|
147
|
+
- `aggregationMode?: "loose" | "guarded" | "strict"` (default: `"loose"`)
|
|
148
|
+
- `"loose"`: No aggregation pipeline validation (all MongoDB stages permitted).
|
|
149
|
+
- `"guarded"`: Denies `$out`, `$merge`, `$function`, `$accumulator`; requires a positive limit, caps max limit, and adds `maxTimeMS`.
|
|
150
|
+
- `"strict"`: Allows only `$match`, `$project`, `$sort`, `$skip`, `$limit` stages; also requires and caps limit.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
```ts
|
|
154
|
+
const db = new K2DB({
|
|
155
|
+
name: "mydb",
|
|
156
|
+
hosts: [{ host: "cluster0.example.mongodb.net" }],
|
|
157
|
+
aggregationMode: "guarded", // disables dangerous stages, enforces limit
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Scope
|
|
162
|
+
|
|
163
|
+
Scope is an optional per-call “owner filter” that k2db can apply to most data access and mutation methods. It provides a safety guardrail to help prevent missing `_owner` filters, especially in multi-tenant or user-data contexts.
|
|
164
|
+
|
|
165
|
+
**Scope is not authentication or authorization:** It does not decide *who* may act. Your API/service is still responsible for authenticating callers and deciding what scopes/owners they are allowed to access.
|
|
166
|
+
|
|
167
|
+
### Type
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
type Scope = string | "*";
|
|
171
|
+
```
|
|
172
|
+
- If a `string` is provided, only documents with `_owner === scope` are included or affected.
|
|
173
|
+
- If `"*"` is provided, *all* owners are included (no owner filter). This is intended for admin/service-to-service operations—never pass untrusted `"*"` from user input.
|
|
174
|
+
|
|
175
|
+
### Modes
|
|
176
|
+
|
|
177
|
+
- In `ownershipMode: "lax"` (default):
|
|
178
|
+
- If `scope` is omitted: No owner filtering is applied (historical behavior; still enforces soft-delete).
|
|
179
|
+
- If `scope` is provided: Only docs with `_owner === scope` (or all docs if `"*"`).
|
|
180
|
+
- In `ownershipMode: "strict"`:
|
|
181
|
+
- `scope` is *required* for all scopified methods (see below). If not provided, k2db throws an error.
|
|
182
|
+
- Use `"*"` for admin/system/service calls (do not invent a magic owner like `_system`).
|
|
183
|
+
|
|
184
|
+
### Methods that support scope
|
|
185
|
+
|
|
186
|
+
The following methods accept an optional `scope?: Scope | string` as their final argument:
|
|
187
|
+
- `get`
|
|
188
|
+
- `findOne`
|
|
189
|
+
- `find`
|
|
190
|
+
- `count`
|
|
191
|
+
- `update`
|
|
192
|
+
- `updateAll`
|
|
193
|
+
- `delete`
|
|
194
|
+
- `deleteAll`
|
|
195
|
+
- `restore`
|
|
196
|
+
- `purge`
|
|
197
|
+
- `purgeDeletedOlderThan`
|
|
198
|
+
- `drop` and `dropDatabase`: For destructive/admin operations, use `"*"` as scope to indicate you intend to operate without owner restriction.
|
|
199
|
+
|
|
200
|
+
### Examples
|
|
201
|
+
|
|
202
|
+
#### a) User reads own docs
|
|
203
|
+
```ts
|
|
204
|
+
// Only docs owned by this user
|
|
205
|
+
await db.find("posts", {}, {}, 0, 20, userId);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### b) Admin/service reads or mutates all docs
|
|
209
|
+
```ts
|
|
210
|
+
// Read all posts (no owner restriction)
|
|
211
|
+
await db.find("posts", {}, {}, 0, 20, "*");
|
|
212
|
+
|
|
213
|
+
// Delete a user (admin only)
|
|
214
|
+
await db.delete("users", id, "*");
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### c) Strict mode with HTTP header mapping
|
|
218
|
+
Suppose your API receives:
|
|
219
|
+
```
|
|
220
|
+
X-Scope: <userId> | *
|
|
221
|
+
```
|
|
222
|
+
You should map this header to the `scope` argument:
|
|
223
|
+
```ts
|
|
224
|
+
const scope = req.headers["x-scope"];
|
|
225
|
+
// Only allow "*" for trusted admin/service credentials!
|
|
226
|
+
await db.find("posts", {}, {}, 0, 20, scope);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Rules of thumb
|
|
230
|
+
- Never pass untrusted `"*"` as a scope—restrict this to trusted admin/service credentials only.
|
|
231
|
+
- Prefer passing a scope everywhere; enabling strict mode helps you catch missing owner filters.
|
|
232
|
+
- Normalize `_owner` values consistently—prefer lowercase and a single convention (e.g., always userId as lowercase UUID).
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
## Aggregation
|
|
236
|
+
|
|
237
|
+
Aggregation in k2db lets you run MongoDB pipelines with guardrails for soft-delete, secure fields, and pipeline safety.
|
|
238
|
+
|
|
239
|
+
### `aggregate(collection, pipeline, skip?, limit?)`
|
|
240
|
+
|
|
241
|
+
Runs an aggregation pipeline on the given collection, returning an array of documents. k2db automatically injects filters and enforces restrictions for safety and consistency.
|
|
242
|
+
|
|
243
|
+
#### What k2db enforces automatically
|
|
244
|
+
|
|
245
|
+
- **Soft-delete enforcement:** Automatically inserts a `$match: { _deleted: { $ne: true } }` stage near the start of your pipeline so only non-deleted documents are returned. For pipelines beginning with `$search`, `$geoNear`, or `$vectorSearch`, the filter is injected after the first stage to avoid breaking those operators.
|
|
246
|
+
- **Nested enforcement:** For `$lookup`, `$unionWith`, `$graphLookup`, and `$facet`, any sub-pipeline is rewritten to ensure the non-deleted filter applies to foreign collections as well. Simple `$lookup` with `localField`/`foreignField` is rewritten to pipeline form so the non-deleted filter can be injected.
|
|
247
|
+
- **Pagination:** If you pass `skip`/`limit`, those are appended to the pipeline. In `"guarded"`/`"strict"` aggregationMode, a positive limit is required and capped to a safe maximum, and `maxTimeMS` is set to prevent long-running queries.
|
|
248
|
+
- **Secure fields:** If `secureFieldPrefixes` is configured (e.g. `["#"]`), any pipeline referencing a secure-prefixed field (such as `"#passport_number"`) is rejected, even in expressions. Returned documents are also stripped of any keys beginning with a secure prefix.
|
|
249
|
+
|
|
250
|
+
#### What you cannot do (by default)
|
|
251
|
+
|
|
252
|
+
- **Return soft-deleted documents:** The injected `$match: { _deleted: { $ne: true } }` filter means aggregate will never return soft-deleted docs, regardless of your pipeline.
|
|
253
|
+
- **Use secure fields in aggregate:** Any pipeline referencing a field like `"#passport_number"` (including in `$project`, `$addFields`, or expressions) is rejected with an error. Even if a document contains a secure-prefixed field, it is stripped from the output.
|
|
254
|
+
- **Use dangerous or non-allowlisted stages:** In `"guarded"` mode, you cannot use `$out`, `$merge`, `$function`, or `$accumulator`. In `"strict"` mode, only `$match`, `$project`, `$sort`, `$skip`, and `$limit` are allowed.
|
|
255
|
+
|
|
256
|
+
#### Filtering level: root vs nested pipelines
|
|
257
|
+
|
|
258
|
+
- The root pipeline always gets the non-deleted filter injected (unless the first stage is `$search`, `$geoNear`, or `$vectorSearch`, in which case it's injected after).
|
|
259
|
+
- For nested pipelines in `$lookup`, `$unionWith`, `$graphLookup`, and `$facet`, k2db rewrites or injects the non-deleted filter into each sub-pipeline, so deleted foreign documents are excluded.
|
|
260
|
+
- If a `$lookup` uses the simple `localField`/`foreignField` form, k2db rewrites it to a pipeline `$lookup` so filtering can be enforced.
|
|
261
|
+
|
|
262
|
+
#### Examples
|
|
263
|
+
|
|
264
|
+
**1) Basic aggregation: injected soft-delete filter**
|
|
265
|
+
|
|
266
|
+
Suppose you call:
|
|
267
|
+
```js
|
|
268
|
+
const pipeline = [
|
|
269
|
+
{ $match: { status: "active" } },
|
|
270
|
+
{ $project: { name: 1, status: 1 } }
|
|
271
|
+
];
|
|
272
|
+
await db.aggregate("users", pipeline);
|
|
273
|
+
```
|
|
274
|
+
**Effective pipeline after k2db injection:**
|
|
275
|
+
```js
|
|
276
|
+
[
|
|
277
|
+
{ $match: { _deleted: { $ne: true } } }, // injected
|
|
278
|
+
{ $match: { status: "active" } },
|
|
279
|
+
{ $project: { name: 1, status: 1 } }
|
|
280
|
+
]
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**2) `$lookup` rewritten for non-deleted foreign docs**
|
|
284
|
+
|
|
285
|
+
Original pipeline:
|
|
286
|
+
```js
|
|
287
|
+
[
|
|
288
|
+
{
|
|
289
|
+
$lookup: {
|
|
290
|
+
from: "orders",
|
|
291
|
+
localField: "_uuid",
|
|
292
|
+
foreignField: "user_id",
|
|
293
|
+
as: "orders"
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
]
|
|
297
|
+
```
|
|
298
|
+
k2db rewrites this to:
|
|
299
|
+
```js
|
|
300
|
+
[
|
|
301
|
+
{
|
|
302
|
+
$lookup: {
|
|
303
|
+
from: "orders",
|
|
304
|
+
let: { local_id: "$_uuid" },
|
|
305
|
+
pipeline: [
|
|
306
|
+
{ $match: { _deleted: { $ne: true } } }, // injected for foreign docs
|
|
307
|
+
{ $match: { $expr: { $eq: ["$user_id", "$$local_id"] } } }
|
|
308
|
+
],
|
|
309
|
+
as: "orders"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
]
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**3) Secure field reference is rejected**
|
|
316
|
+
|
|
317
|
+
Pipeline:
|
|
318
|
+
```js
|
|
319
|
+
[
|
|
320
|
+
{ $project: { name: 1, passport: "$#passport_number" } }
|
|
321
|
+
]
|
|
322
|
+
```
|
|
323
|
+
Result: **Throws an error** — referencing a secure-prefixed field (`"#passport_number"`) is not allowed in aggregate pipelines.
|
|
324
|
+
|
|
325
|
+
**4) Attempting to aggregate deleted docs returns nothing**
|
|
326
|
+
|
|
327
|
+
Pipeline:
|
|
328
|
+
```js
|
|
329
|
+
[
|
|
330
|
+
{ $match: { _deleted: true } }
|
|
331
|
+
]
|
|
332
|
+
```
|
|
333
|
+
Result: Returns **no documents** — k2db injects `{ _deleted: { $ne: true } }` before your match, so the result set is always empty.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
> **Note:** k2db's aggregation guardrails ensure you cannot accidentally leak deleted or secure data, or run dangerous stages in stricter modes.
|
|
338
|
+
|
|
339
|
+
## Secure fields and encryption at rest
|
|
340
|
+
|
|
341
|
+
k2db supports **secure fields**: fields whose keys start with a configurable prefix (recommended: `#`). Secure fields are designed to be hard to accidentally leak in bulk queries and hard to casually access in code.
|
|
342
|
+
|
|
343
|
+
This feature has two layers:
|
|
344
|
+
|
|
345
|
+
1) **Guardrails (always):** Secure fields are stripped from multi-record reads (`find`, `aggregate`) and cannot be explicitly projected. Aggregation pipelines are also rejected if they reference secure fields.
|
|
346
|
+
2) **Encryption at rest (optional):** When enabled, secure-field values are encrypted before being written to MongoDB and decrypted on single-record reads.
|
|
347
|
+
|
|
348
|
+
### Why `#` (friction) instead of `__somethingNice`
|
|
349
|
+
|
|
350
|
+
The goal is to make secure fields “visually wrong” and **ergonomically annoying** to access on purpose:
|
|
351
|
+
|
|
352
|
+
- `doc["#passport_number"]` is explicit and reviewable.
|
|
353
|
+
- `doc.#passport_number` is impossible (forces bracket access).
|
|
354
|
+
- It discourages lazy DTO copying and casual destructuring that can accidentally leak secrets.
|
|
355
|
+
|
|
356
|
+
Using `__private` looks “normal”, which increases the chance developers will treat it like any other field and accidentally return it in list endpoints.
|
|
357
|
+
|
|
358
|
+
Also, `_...` is reserved for k2db’s metadata (`_uuid`, `_owner`, `_created`, …), so `#...` cleanly avoids that namespace.
|
|
359
|
+
|
|
360
|
+
### Configuration
|
|
361
|
+
|
|
362
|
+
Enable secure fields by setting `secureFieldPrefixes`. To also encrypt them at rest, provide a base64 AES-256 key and a key id.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
const db = new K2DB({
|
|
366
|
+
name: "mydb",
|
|
367
|
+
hosts: [{ host: "cluster0.example.mongodb.net" }],
|
|
368
|
+
|
|
369
|
+
// Treat "#..." fields as secure
|
|
370
|
+
secureFieldPrefixes: ["#"],
|
|
371
|
+
|
|
372
|
+
// Optional encryption-at-rest for secure fields:
|
|
373
|
+
// - must decode to 32 bytes (AES-256)
|
|
374
|
+
// - values are stored as "<keyid>:<payload>"
|
|
375
|
+
secureFieldEncryptionKeyId: "k1",
|
|
376
|
+
secureFieldEncryptionKey: process.env.K2DB_SECURE_KEY_B64,
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Key requirements:
|
|
381
|
+
- `secureFieldEncryptionKey` must be **base64** and decode to **32 bytes**.
|
|
382
|
+
- Encryption is enabled only when **both** `secureFieldEncryptionKey` and `secureFieldEncryptionKeyId` are provided.
|
|
383
|
+
|
|
384
|
+
> Note: Today k2db decrypts only ciphertexts whose `keyid` matches the configured `secureFieldEncryptionKeyId`. If the stored `keyid` differs, the encrypted string is returned as-is (useful during key rotation rollout, but plan your rotation strategy accordingly).
|
|
385
|
+
|
|
386
|
+
### Storage format
|
|
387
|
+
|
|
388
|
+
When encryption is enabled, each secure field value is stored as a single string:
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
<keyid>:<ivB64>.<tagB64>.<ctB64>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- Algorithm: AES-256-GCM
|
|
395
|
+
- Plaintext: `JSON.stringify(value)` (so secure fields may be strings, numbers, objects, arrays, etc.)
|
|
396
|
+
|
|
397
|
+
### Behavior by method
|
|
398
|
+
|
|
399
|
+
With `secureFieldPrefixes: ["#"]`:
|
|
400
|
+
|
|
401
|
+
- **create / update / updateAll**:
|
|
402
|
+
- If encryption is enabled, `#...` values are encrypted before write.
|
|
403
|
+
- If encryption is disabled, values are stored as provided.
|
|
404
|
+
- **get / findOne** (single-record reads):
|
|
405
|
+
- If encryption is enabled, `#...` values are decrypted and returned in the object.
|
|
406
|
+
- If encryption is disabled, values are returned as stored.
|
|
407
|
+
- **find** (multi-record reads):
|
|
408
|
+
- Secure fields are **stripped** from every returned document (even if encryption is disabled).
|
|
409
|
+
- **aggregate**:
|
|
410
|
+
- Secure fields are **not allowed to be referenced** in the pipeline (k2db throws).
|
|
411
|
+
- Secure fields are also **stripped** from returned documents as a second safety net.
|
|
412
|
+
- **projections**:
|
|
413
|
+
- `findOne(fields=[...])` and `find(params.filter=[...])` cannot include secure fields (throws).
|
|
414
|
+
|
|
415
|
+
### Examples
|
|
416
|
+
|
|
417
|
+
#### 1) Store a secure field
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const owner = userId.toLowerCase();
|
|
421
|
+
|
|
422
|
+
const { id } = await db.create("profiles", owner, {
|
|
423
|
+
name: "Ada",
|
|
424
|
+
"#passport_number": "123456789",
|
|
425
|
+
"#home_address": { line1: "1 Example St", city: "Perth" },
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
- If encryption is enabled, MongoDB stores `"#passport_number"` and `"#home_address"` as encrypted strings.
|
|
430
|
+
- If encryption is disabled, they are stored as plaintext (still guarded on reads).
|
|
431
|
+
|
|
432
|
+
#### 2) Single-record read returns secure fields
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
const profile = await db.get("profiles", id, owner);
|
|
436
|
+
|
|
437
|
+
// Explicit access (friction by design)
|
|
438
|
+
console.log(profile["#passport_number"]);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
#### 3) Multi-record read strips secure fields
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
const list = await db.find("profiles", {}, {}, 0, 50, owner);
|
|
445
|
+
|
|
446
|
+
// "#..." fields are removed from each document in list
|
|
447
|
+
console.log(list[0]["#passport_number"]); // undefined
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
#### 4) Aggregation cannot reference secure fields
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
await db.aggregate("profiles", [
|
|
454
|
+
{ $project: { name: 1, passport: "$#passport_number" } },
|
|
455
|
+
]);
|
|
456
|
+
// throws: secure-prefixed field referenced in pipeline
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### What this is (and isn’t)
|
|
460
|
+
|
|
461
|
+
- This is a **data safety guardrail** and optional encryption-at-rest mechanism.
|
|
462
|
+
- It is **not authentication/authorization**: you still must decide who the caller is and what they’re allowed to do.
|
|
463
|
+
- For admin/service operations, combine this with **Scope** rules: do not accept `"*"` from untrusted callers.
|
|
464
|
+
|
|
127
465
|
Environment loader
|
|
128
466
|
```ts
|
|
129
467
|
const conf = K2DB.fromEnv(); // K2DB_NAME (logical db), K2DB_HOSTS, K2DB_USER, K2DB_PASSWORD, K2DB_AUTH_SOURCE, K2DB_REPLICASET, K2DB_SLOW_MS
|
|
@@ -305,19 +643,19 @@ db.clearSchema("hello");
|
|
|
305
643
|
- `VersionInfo`: `{ _uuid: string; _v: number; _at: number }`
|
|
306
644
|
|
|
307
645
|
Returns by method
|
|
308
|
-
- `get(collection, id)`: `Promise<BaseDocument>`
|
|
309
|
-
- `find(collection, filter, params?, skip?, limit?)`: `Promise<BaseDocument[]>`
|
|
310
|
-
- `findOne(collection, criteria, fields?)`: `Promise<BaseDocument|null>`
|
|
646
|
+
- `get(collection, id, scope?)`: `Promise<BaseDocument>`
|
|
647
|
+
- `find(collection, filter, params?, skip?, limit?, scope?)`: `Promise<BaseDocument[]>`
|
|
648
|
+
- `findOne(collection, criteria, fields?, scope?)`: `Promise<BaseDocument|null>`
|
|
311
649
|
- `aggregate(collection, pipeline, skip?, limit?)`: `Promise<BaseDocument[]>`
|
|
312
650
|
- `create(collection, owner, data)`: `Promise<CreateResult>`
|
|
313
|
-
- `update(collection, id, data, replace?)`: `Promise<UpdateResult>`
|
|
314
|
-
- `updateAll(collection, criteria, values)`: `Promise<UpdateResult>`
|
|
315
|
-
- `delete(collection, id)`: `Promise<DeleteResult>`
|
|
316
|
-
- `deleteAll(collection, criteria)`: `Promise<DeleteResult>`
|
|
317
|
-
- `purge(collection, id)`: `Promise<PurgeResult>`
|
|
318
|
-
- `restore(collection, criteria)`: `Promise<RestoreResult>`
|
|
319
|
-
- `count(collection, criteria)`: `Promise<CountResult>`
|
|
320
|
-
- `drop(collection)`: `Promise<DropResult>`
|
|
651
|
+
- `update(collection, id, data, replace?, scope?)`: `Promise<UpdateResult>`
|
|
652
|
+
- `updateAll(collection, criteria, values, scope?)`: `Promise<UpdateResult>`
|
|
653
|
+
- `delete(collection, id, scope?)`: `Promise<DeleteResult>`
|
|
654
|
+
- `deleteAll(collection, criteria, scope?)`: `Promise<DeleteResult>`
|
|
655
|
+
- `purge(collection, id, scope?)`: `Promise<PurgeResult>`
|
|
656
|
+
- `restore(collection, criteria, scope?)`: `Promise<RestoreResult>`
|
|
657
|
+
- `count(collection, criteria, scope?)`: `Promise<CountResult>`
|
|
658
|
+
- `drop(collection, scope?)`: `Promise<DropResult>`
|
|
321
659
|
- `ensureIndexes(collection, opts?)`: `Promise<void>`
|
|
322
660
|
- `ensureHistoryIndexes(collection)`: `Promise<void>`
|
|
323
661
|
- `updateVersioned(collection, id, data, replace?, maxVersions?)`: `Promise<VersionedUpdateResult[]>`
|
package/db.d.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface DatabaseConfig {
|
|
|
23
23
|
beforeQuery?: (op: string, details: any) => void;
|
|
24
24
|
afterQuery?: (op: string, details: any, durationMs: number) => void;
|
|
25
25
|
};
|
|
26
|
+
ownershipMode?: "lax" | "strict";
|
|
27
|
+
aggregationMode?: "loose" | "guarded" | "strict";
|
|
28
|
+
secureFieldPrefixes?: string[];
|
|
29
|
+
secureFieldEncryptionKey?: string;
|
|
30
|
+
secureFieldEncryptionKeyId?: string;
|
|
26
31
|
}
|
|
27
32
|
export interface BaseDocument {
|
|
28
33
|
_id?: ObjectId;
|
|
@@ -67,6 +72,7 @@ export interface VersionInfo {
|
|
|
67
72
|
_v: number;
|
|
68
73
|
_at: number;
|
|
69
74
|
}
|
|
75
|
+
export type Scope = string | "*";
|
|
70
76
|
export declare class K2DB {
|
|
71
77
|
private conf;
|
|
72
78
|
private db;
|
|
@@ -75,7 +81,20 @@ export declare class K2DB {
|
|
|
75
81
|
private initialized;
|
|
76
82
|
private initPromise?;
|
|
77
83
|
private schemas;
|
|
84
|
+
private readonly ownershipMode;
|
|
85
|
+
private readonly aggregationMode;
|
|
86
|
+
private readonly secureFieldPrefixes;
|
|
87
|
+
private readonly secureFieldEncryptionKey?;
|
|
88
|
+
private readonly secureFieldEncryptionKeyId?;
|
|
78
89
|
constructor(conf: DatabaseConfig);
|
|
90
|
+
/**
|
|
91
|
+
* Normalize a scope value for ownership enforcement.
|
|
92
|
+
*/
|
|
93
|
+
private normalizeScope;
|
|
94
|
+
/**
|
|
95
|
+
* Apply a scope constraint to criteria for ownership enforcement.
|
|
96
|
+
*/
|
|
97
|
+
private applyScopeToCriteria;
|
|
79
98
|
/**
|
|
80
99
|
* Initializes the MongoDB connection.
|
|
81
100
|
*/
|
|
@@ -91,15 +110,21 @@ export declare class K2DB {
|
|
|
91
110
|
* @param collectionName - Name of the collection.
|
|
92
111
|
*/
|
|
93
112
|
private getCollection;
|
|
94
|
-
get(collectionName: string, uuid: string): Promise<BaseDocument>;
|
|
95
113
|
/**
|
|
96
114
|
* Retrieves a single document by UUID.
|
|
97
115
|
* @param collectionName - Name of the collection.
|
|
98
116
|
* @param uuid - UUID of the document.
|
|
99
|
-
* @param
|
|
117
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
118
|
+
*/
|
|
119
|
+
get(collectionName: string, uuid: string, scope?: Scope | string): Promise<BaseDocument>;
|
|
120
|
+
/**
|
|
121
|
+
* Retrieves a single document by criteria.
|
|
122
|
+
* @param collectionName - Name of the collection.
|
|
123
|
+
* @param criteria - Criteria to find the document.
|
|
100
124
|
* @param fields - Optional array of fields to include.
|
|
125
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
101
126
|
*/
|
|
102
|
-
findOne(collectionName: string, criteria: any, fields?: Array<string
|
|
127
|
+
findOne(collectionName: string, criteria: any, fields?: Array<string>, scope?: Scope | string): Promise<BaseDocument | null>;
|
|
103
128
|
/**
|
|
104
129
|
* Finds documents based on parameters with pagination support.
|
|
105
130
|
* @param collectionName - Name of the collection.
|
|
@@ -107,16 +132,72 @@ export declare class K2DB {
|
|
|
107
132
|
* @param params - Optional search parameters (for sorting, including/excluding fields).
|
|
108
133
|
* @param skip - Number of documents to skip (for pagination).
|
|
109
134
|
* @param limit - Maximum number of documents to return.
|
|
135
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
110
136
|
*/
|
|
111
|
-
find(collectionName: string, filter: any, params?: any, skip?: number, limit?: number): Promise<BaseDocument[]>;
|
|
137
|
+
find(collectionName: string, filter: any, params?: any, skip?: number, limit?: number, scope?: Scope | string): Promise<BaseDocument[]>;
|
|
112
138
|
/**
|
|
113
|
-
* Aggregates documents based on criteria with pagination support.
|
|
139
|
+
* Aggregates documents based on criteria with pagination support (may validate/limit stages in guarded/strict aggregationMode). Secure-prefixed fields may be stripped from results when configured.
|
|
114
140
|
* @param collectionName - Name of the collection.
|
|
115
141
|
* @param criteria - Aggregation pipeline criteria.
|
|
116
142
|
* @param skip - Number of documents to skip (for pagination).
|
|
117
143
|
* @param limit - Maximum number of documents to return.
|
|
144
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
145
|
+
*/
|
|
146
|
+
aggregate(collectionName: string, criteria: any[], skip?: number, limit?: number, scope?: Scope | string): Promise<BaseDocument[]>;
|
|
147
|
+
/**
|
|
148
|
+
* Validate an aggregation pipeline for safety based on aggregationMode.
|
|
149
|
+
* - loose: no validation
|
|
150
|
+
* - guarded: deny obvious footguns (writes, server-side code) and enforce basic caps
|
|
151
|
+
* - strict: allow only a small safe subset of stages and enforce basic caps
|
|
152
|
+
*/
|
|
153
|
+
private validateAggregationPipeline;
|
|
154
|
+
/** Collect top-level stage operators for a pipeline (e.g. "$match", "$lookup"). */
|
|
155
|
+
private collectStageOps;
|
|
156
|
+
/** True if a field key is considered secure and must not be returned. */
|
|
157
|
+
private isSecureFieldKey;
|
|
158
|
+
/**
|
|
159
|
+
* Recursively strips secure-prefixed fields from objects/arrays (e.g. "#passport_number").
|
|
160
|
+
* This is applied on read results (e.g. aggregate) so pipelines cannot exfiltrate secure fields.
|
|
161
|
+
*/
|
|
162
|
+
private stripSecureFieldsDeep;
|
|
163
|
+
/** True if secure-field encryption is enabled (requires both key and keyId). */
|
|
164
|
+
private hasSecureEncryption;
|
|
165
|
+
/** Encrypt a JS value using AES-256-GCM and return "<kid>:<ivB64>.<tagB64>.<ctB64>". */
|
|
166
|
+
private encryptSecureValueToString;
|
|
167
|
+
/** Decrypt a "<kid>:<ivB64>.<tagB64>.<ctB64>" string back into a JS value. */
|
|
168
|
+
private decryptSecureStringToValue;
|
|
169
|
+
/**
|
|
170
|
+
* Encrypt secure-prefixed fields in an object/array tree.
|
|
171
|
+
* Only keys with secure prefixes are encrypted; other keys are recursed to allow nested secure keys.
|
|
172
|
+
*/
|
|
173
|
+
private encryptSecureFieldsDeep;
|
|
174
|
+
/**
|
|
175
|
+
* Decrypt secure-prefixed fields in an object/array tree.
|
|
176
|
+
* If a secure field value is not an encrypted string, it is returned as-is.
|
|
177
|
+
*/
|
|
178
|
+
private decryptSecureFieldsDeep;
|
|
179
|
+
/**
|
|
180
|
+
* Throws if an aggregation pipeline references any secure-prefixed field paths.
|
|
181
|
+
* This prevents deriving output from secure fields (e.g. {$group: {x: {$sum: "$#passport_number"}}}).
|
|
118
182
|
*/
|
|
119
|
-
|
|
183
|
+
private assertNoSecureFieldRefsInPipeline;
|
|
184
|
+
/** Recursively detects secure field references in an aggregation pipeline AST. */
|
|
185
|
+
private containsSecureFieldRefDeep;
|
|
186
|
+
/**
|
|
187
|
+
* Returns true if a string appears to reference a secure field path.
|
|
188
|
+
* We consider:
|
|
189
|
+
* - "$path.to.field"
|
|
190
|
+
* - "$$var.path.to.field"
|
|
191
|
+
* - plain "path.to.field" (e.g. $getField: { field: "#passport_number" })
|
|
192
|
+
*/
|
|
193
|
+
private stringHasSecureFieldPath;
|
|
194
|
+
/** True if any segment in a dotted path starts with a secure prefix. */
|
|
195
|
+
private pathHasSecureSegment;
|
|
196
|
+
/**
|
|
197
|
+
* Ensures an aggregation pipeline respects ownership scope for the root
|
|
198
|
+
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
199
|
+
*/
|
|
200
|
+
private enforceScopeInPipeline;
|
|
120
201
|
/**
|
|
121
202
|
* Ensures an aggregation pipeline excludes soft-deleted documents for the root
|
|
122
203
|
* collection and any joined collections ($lookup, $unionWith, $graphLookup, $facet).
|
|
@@ -135,8 +216,9 @@ export declare class K2DB {
|
|
|
135
216
|
* @param collectionName - Name of the collection.
|
|
136
217
|
* @param criteria - Update criteria.
|
|
137
218
|
* @param values - Values to update or replace with.
|
|
219
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
138
220
|
*/
|
|
139
|
-
updateAll(collectionName: string, criteria: any, values: Partial<BaseDocument
|
|
221
|
+
updateAll(collectionName: string, criteria: any, values: Partial<BaseDocument>, scope?: Scope | string): Promise<UpdateResult>;
|
|
140
222
|
/**
|
|
141
223
|
* Updates a single document by UUID.
|
|
142
224
|
* Can either replace the document or patch it.
|
|
@@ -144,60 +226,70 @@ export declare class K2DB {
|
|
|
144
226
|
* @param id - UUID string to identify the document.
|
|
145
227
|
* @param data - Data to update or replace with.
|
|
146
228
|
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
229
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
147
230
|
*/
|
|
148
|
-
update(collectionName: string, id: string, data: Partial<BaseDocument>, replace?: boolean): Promise<UpdateResult>;
|
|
231
|
+
update(collectionName: string, id: string, data: Partial<BaseDocument>, replace?: boolean, scope?: Scope | string): Promise<UpdateResult>;
|
|
149
232
|
/**
|
|
150
233
|
* Removes (soft deletes) multiple documents based on criteria.
|
|
151
234
|
* @param collectionName - Name of the collection.
|
|
152
235
|
* @param criteria - Removal criteria.
|
|
236
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
153
237
|
*/
|
|
154
|
-
deleteAll(collectionName: string, criteria: any): Promise<DeleteResult>;
|
|
238
|
+
deleteAll(collectionName: string, criteria: any, scope?: Scope | string): Promise<DeleteResult>;
|
|
155
239
|
/**
|
|
156
240
|
* Removes (soft deletes) a single document by UUID.
|
|
157
241
|
* @param collectionName - Name of the collection.
|
|
158
242
|
* @param id - UUID of the document.
|
|
243
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
159
244
|
*/
|
|
160
|
-
delete(collectionName: string, id: string): Promise<DeleteResult>;
|
|
245
|
+
delete(collectionName: string, id: string, scope?: Scope | string): Promise<DeleteResult>;
|
|
161
246
|
/**
|
|
162
247
|
* Permanently deletes a document that has been soft-deleted.
|
|
163
248
|
* @param collectionName - Name of the collection.
|
|
164
249
|
* @param id - UUID of the document.
|
|
250
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
165
251
|
*/
|
|
166
|
-
purge(collectionName: string, id: string): Promise<PurgeResult>;
|
|
252
|
+
purge(collectionName: string, id: string, scope?: Scope | string): Promise<PurgeResult>;
|
|
167
253
|
/**
|
|
168
254
|
* Permanently deletes all documents that are soft-deleted and whose _updated
|
|
169
255
|
* timestamp is older than the provided threshold (in milliseconds ago).
|
|
170
256
|
* @param collectionName - Name of the collection.
|
|
171
257
|
* @param olderThanMs - Age threshold in milliseconds; documents with
|
|
172
258
|
* `_updated <= (Date.now() - olderThanMs)` will be purged.
|
|
259
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
173
260
|
*/
|
|
174
|
-
purgeDeletedOlderThan(collectionName: string, olderThanMs: number): Promise<PurgeManyResult>;
|
|
261
|
+
purgeDeletedOlderThan(collectionName: string, olderThanMs: number, scope?: Scope | string): Promise<PurgeManyResult>;
|
|
175
262
|
/**
|
|
176
263
|
* Restores a soft-deleted document.
|
|
177
264
|
* @param collectionName - Name of the collection.
|
|
178
265
|
* @param criteria - Criteria to identify the document.
|
|
266
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
179
267
|
*/
|
|
180
|
-
restore(collectionName: string, criteria: any): Promise<RestoreResult>;
|
|
268
|
+
restore(collectionName: string, criteria: any, scope?: Scope | string): Promise<RestoreResult>;
|
|
181
269
|
/**
|
|
182
270
|
* Counts documents based on criteria.
|
|
183
271
|
* @param collectionName - Name of the collection.
|
|
184
272
|
* @param criteria - Counting criteria.
|
|
273
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
185
274
|
*/
|
|
186
|
-
count(collectionName: string, criteria: any): Promise<CountResult>;
|
|
275
|
+
count(collectionName: string, criteria: any, scope?: Scope | string): Promise<CountResult>;
|
|
187
276
|
/**
|
|
188
|
-
* Drops an entire collection.
|
|
277
|
+
* Drops an entire collection (global destructive operation).
|
|
189
278
|
* @param collectionName - Name of the collection.
|
|
279
|
+
* @param scope - (optional) Must be "*" in strict ownership mode.
|
|
190
280
|
*/
|
|
191
|
-
drop(collectionName: string): Promise<DropResult>;
|
|
281
|
+
drop(collectionName: string, scope?: Scope | string): Promise<DropResult>;
|
|
192
282
|
/**
|
|
193
283
|
* Sanitizes aggregation criteria.
|
|
194
284
|
* @param criteria - Aggregation stage criteria.
|
|
195
285
|
*/
|
|
196
286
|
private static sanitiseCriteria;
|
|
197
|
-
/** Recursively
|
|
287
|
+
/** Recursively normalizes query fields: `_uuid` uppercased, `_owner` lowercased. */
|
|
198
288
|
private static normalizeCriteriaIds;
|
|
199
289
|
/** Uppercase helper for `_uuid` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
200
290
|
private static normalizeUuidField;
|
|
291
|
+
/** Lowercase helper for `_owner` field supporting operators like $in/$nin/$eq/$ne and arrays. */
|
|
292
|
+
private static normalizeOwnerField;
|
|
201
293
|
/** Strip any user-provided fields that start with '_' (reserved). */
|
|
202
294
|
private static stripReservedFields;
|
|
203
295
|
/** True if string matches K2 ID format (Crockford Base32, 8-4-4-4-6, uppercase). */
|
|
@@ -274,12 +366,31 @@ export declare class K2DB {
|
|
|
274
366
|
/**
|
|
275
367
|
* Update a document and keep the previous version in a history collection.
|
|
276
368
|
* If maxVersions is provided, prunes oldest snapshots beyond that number.
|
|
369
|
+
* @param collectionName - Name of the collection.
|
|
370
|
+
* @param id - UUID string to identify the document.
|
|
371
|
+
* @param data - Data to update or replace with.
|
|
372
|
+
* @param replace - If true, replaces the entire document (PUT), otherwise patches (PATCH).
|
|
373
|
+
* @param maxVersions - Maximum number of versions to keep (optional).
|
|
374
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
375
|
+
*/
|
|
376
|
+
updateVersioned(collectionName: string, id: string, data: Partial<BaseDocument>, replace?: boolean, maxVersions?: number, scope?: Scope | string): Promise<VersionedUpdateResult[]>;
|
|
377
|
+
/**
|
|
378
|
+
* List versions (latest first).
|
|
379
|
+
* @param collectionName - Name of the collection.
|
|
380
|
+
* @param id - UUID string to identify the document.
|
|
381
|
+
* @param skip - Number of versions to skip (for pagination).
|
|
382
|
+
* @param limit - Maximum number of versions to return.
|
|
383
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
384
|
+
*/
|
|
385
|
+
listVersions(collectionName: string, id: string, skip?: number, limit?: number, scope?: Scope | string): Promise<VersionInfo[]>;
|
|
386
|
+
/**
|
|
387
|
+
* Revert the current document to a specific historical version (preserves metadata).
|
|
388
|
+
* @param collectionName - Name of the collection.
|
|
389
|
+
* @param id - UUID string to identify the document.
|
|
390
|
+
* @param version - Version number to revert to.
|
|
391
|
+
* @param scope - (optional) Owner selector; "*" means all owners.
|
|
277
392
|
*/
|
|
278
|
-
|
|
279
|
-
/** List versions (latest first). */
|
|
280
|
-
listVersions(collectionName: string, id: string, skip?: number, limit?: number): Promise<VersionInfo[]>;
|
|
281
|
-
/** Revert the current document to a specific historical version (preserves metadata). */
|
|
282
|
-
revertToVersion(collectionName: string, id: string, version: number): Promise<{
|
|
393
|
+
revertToVersion(collectionName: string, id: string, version: number, scope?: Scope | string): Promise<{
|
|
283
394
|
updated: number;
|
|
284
395
|
}>;
|
|
285
396
|
}
|