@blamejs/core 0.8.52 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/db-collection.js +290 -0
  9. package/lib/db-query.js +245 -0
  10. package/lib/db.js +173 -67
  11. package/lib/framework-error.js +55 -0
  12. package/lib/guard-cidr.js +2 -1
  13. package/lib/guard-jwt.js +2 -2
  14. package/lib/guard-oauth.js +2 -2
  15. package/lib/http-client-cache.js +916 -0
  16. package/lib/http-client.js +242 -0
  17. package/lib/mail-arf.js +343 -0
  18. package/lib/mail-auth.js +265 -40
  19. package/lib/mail-bimi.js +948 -33
  20. package/lib/mail-bounce.js +386 -4
  21. package/lib/mail-mdn.js +424 -0
  22. package/lib/mail-unsubscribe.js +265 -25
  23. package/lib/mail.js +403 -21
  24. package/lib/middleware/bearer-auth.js +1 -1
  25. package/lib/middleware/clear-site-data.js +122 -0
  26. package/lib/middleware/dpop.js +1 -1
  27. package/lib/middleware/index.js +9 -0
  28. package/lib/middleware/nel.js +214 -0
  29. package/lib/middleware/security-headers.js +56 -4
  30. package/lib/middleware/speculation-rules.js +323 -0
  31. package/lib/mime-parse.js +198 -0
  32. package/lib/mtls-ca.js +15 -5
  33. package/lib/network-dns.js +890 -27
  34. package/lib/network-tls.js +745 -0
  35. package/lib/object-store/sigv4.js +54 -0
  36. package/lib/public-suffix.js +414 -0
  37. package/lib/safe-buffer.js +7 -0
  38. package/lib/safe-json.js +1 -1
  39. package/lib/static.js +120 -0
  40. package/lib/storage.js +11 -0
  41. package/lib/vendor/MANIFEST.json +33 -0
  42. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  43. package/lib/vendor/public-suffix-list.dat +16376 -0
  44. package/package.json +1 -1
  45. package/sbom.cyclonedx.json +6 -6
@@ -121,20 +121,55 @@ async function verifyRegistration(opts) {
121
121
  _requireString(opts.expectedOrigin, "expectedOrigin");
122
122
  _requireString(opts.expectedRPID, "expectedRPID");
123
123
 
124
- return await _vendor().verifyRegistrationResponse({
124
+ var rv = await _vendor().verifyRegistrationResponse({
125
125
  response: opts.response,
126
126
  expectedChallenge: opts.expectedChallenge,
127
127
  expectedOrigin: opts.expectedOrigin,
128
128
  expectedRPID: opts.expectedRPID,
129
129
  requireUserVerification: opts.requireUserVerification !== false,
130
130
  });
131
+ // WebAuthn L3 §6.1.3 — surface authenticator-data BE/BS flags as
132
+ // named fields. backupEligible (BE) signals the credential CAN be
133
+ // backed up to a cloud account; backupState (BS) signals it IS
134
+ // currently backed up. Operators key trust decisions on these
135
+ // (single-device passkey → require step-up; multi-device synced
136
+ // passkey → strong signal). The vendor parses authData and exposes
137
+ // credentialDeviceType ("singleDevice" | "multiDevice") and
138
+ // credentialBackedUp (boolean) on registrationInfo; we map them to
139
+ // the spec's flag names and add them to the top-level result so
140
+ // callers don't have to dig through registrationInfo.
141
+ if (rv && rv.registrationInfo) {
142
+ rv.backupEligible = rv.registrationInfo.credentialDeviceType === "multiDevice";
143
+ rv.backupState = rv.registrationInfo.credentialBackedUp === true;
144
+ } else {
145
+ rv = rv || {};
146
+ rv.backupEligible = false;
147
+ rv.backupState = false;
148
+ }
149
+ return rv;
131
150
  }
132
151
 
133
152
  // ---- Authentication ----
134
153
 
154
+ // startAuthentication accepts an optional `mediation` token that the
155
+ // caller passes through verbatim to the browser as
156
+ // `navigator.credentials.get({ publicKey, mediation })`. The descriptor
157
+ // itself doesn't carry mediation — it's a separate argument on the
158
+ // page — but startAuthentication echoes it onto the returned options
159
+ // so the operator's transport (typically a JSON GET) carries it to
160
+ // the page without losing the value. Allowed tokens per the W3C
161
+ // Credential Management spec: "silent" / "optional" / "required" /
162
+ // "conditional". "conditional" enables passkey autofill on
163
+ // <input autocomplete="webauthn">.
164
+ var ALLOWED_MEDIATION = { silent: 1, optional: 1, required: 1, conditional: 1 };
165
+
135
166
  async function startAuthentication(opts) {
136
167
  if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
137
168
  _requireString(opts.rpId, "rpId");
169
+ if (opts.mediation !== undefined && !ALLOWED_MEDIATION[opts.mediation]) {
170
+ throw new AuthError("auth-passkey/bad-mediation",
171
+ "mediation must be one of silent/optional/required/conditional");
172
+ }
138
173
 
139
174
  var options = await _vendor().generateAuthenticationOptions({
140
175
  rpID: opts.rpId,
@@ -148,9 +183,169 @@ async function startAuthentication(opts) {
148
183
  } else {
149
184
  options.hints = opts.hints;
150
185
  }
186
+ if (opts.mediation !== undefined) {
187
+ options.mediation = opts.mediation;
188
+ }
189
+ return options;
190
+ }
191
+
192
+ // conditionalAuthOptions — convenience wrapper for the passkey-autofill
193
+ // flow (mediation: "conditional"). Browsers require an empty
194
+ // allowCredentials list, presence-only userVerification (so the
195
+ // autofill chip can surface without forcing biometric), and a present
196
+ // challenge. Returns an object shaped for
197
+ // `navigator.credentials.get({ publicKey: <opts>, mediation: "conditional" })`.
198
+ async function conditionalAuthOptions(opts) {
199
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
200
+ _requireString(opts.rpId, "rpId");
201
+
202
+ var options = await _vendor().generateAuthenticationOptions({
203
+ rpID: opts.rpId,
204
+ // For conditional UI the spec mandates an empty allowCredentials
205
+ // list — discoverable credentials only. Supplying a list here
206
+ // suppresses the autofill chip in current browsers.
207
+ allowCredentials: [],
208
+ userVerification: opts.userVerification || "preferred",
209
+ timeout: opts.timeout,
210
+ extensions: opts.extensions,
211
+ });
212
+ options.mediation = "conditional";
213
+ if (!opts.hints) {
214
+ options.hints = ["client-device", "hybrid"];
215
+ } else {
216
+ options.hints = opts.hints;
217
+ }
151
218
  return options;
152
219
  }
153
220
 
221
+ // ---- WebAuthn L3 extension helpers (PRF / largeBlob / credBlob) ----
222
+ //
223
+ // Pre-compute the spec-correct shape so callers don't have to remember
224
+ // (a) what the field is called this year, (b) which inputs travel as
225
+ // base64url vs Uint8Array, (c) which support the {support:"required"}
226
+ // contract. Validation tier: throw at config-time. Misuse here is a
227
+ // coding bug, not a request-shape thing.
228
+
229
+ function _b64urlExtInput(value, name) {
230
+ // Accept a base64url string OR a Buffer / Uint8Array. Normalize the
231
+ // wire shape to base64url (the JSON descriptor ships base64url; the
232
+ // browser turns it into an ArrayBuffer before passing to the
233
+ // authenticator).
234
+ if (typeof value === "string") {
235
+ if (value.length === 0 || !safeBuffer.BASE64URL_RE.test(value)) {
236
+ throw new AuthError("auth-passkey/bad-extension-input",
237
+ name + " must be base64url (no padding) when string");
238
+ }
239
+ return value;
240
+ }
241
+ if (Buffer.isBuffer(value)) {
242
+ return value.toString("base64url");
243
+ }
244
+ if (value instanceof Uint8Array) {
245
+ return Buffer.from(value).toString("base64url");
246
+ }
247
+ throw new AuthError("auth-passkey/bad-extension-input",
248
+ name + " must be base64url string, Buffer, or Uint8Array");
249
+ }
250
+
251
+ // PRF (Pseudo-Random Function) extension — WebAuthn L3 §10.1.2.
252
+ // Authenticator-bound HKDF source. eval inputs are 32-byte salts; the
253
+ // authenticator returns deterministic 32-byte outputs the operator
254
+ // uses as a key-encryption key (vault unlock, file-encryption seed).
255
+ // Shape: `{ prf: { eval: { first, second? } } }` per extension-id "prf".
256
+ function _prfExt(args) {
257
+ if (!args || !args.eval) {
258
+ throw new AuthError("auth-passkey/missing-eval",
259
+ "extensions.prf({ eval: { first, second? } }) is required");
260
+ }
261
+ if (args.eval.first === undefined || args.eval.first === null) {
262
+ throw new AuthError("auth-passkey/missing-prf-first",
263
+ "extensions.prf eval.first is required");
264
+ }
265
+ var out = { prf: { eval: { first: _b64urlExtInput(args.eval.first, "eval.first") } } };
266
+ if (args.eval.second !== undefined && args.eval.second !== null) {
267
+ out.prf.eval.second = _b64urlExtInput(args.eval.second, "eval.second");
268
+ }
269
+ return out;
270
+ }
271
+
272
+ // largeBlob extension — WebAuthn L3 §10.3.
273
+ // Per-credential opaque blob storage. At registration the operator
274
+ // asks for support: "preferred" | "required". At auth time the
275
+ // operator asks to read OR write, never both in the same assertion.
276
+ function _largeBlobExt(args) {
277
+ if (!args) {
278
+ throw new AuthError("auth-passkey/missing-largeblob",
279
+ "extensions.largeBlob({ support? | read? | write? }) is required");
280
+ }
281
+ var out = { largeBlob: {} };
282
+ var SUPPORT = { preferred: 1, required: 1 };
283
+ var modes = 0;
284
+ if (args.support !== undefined) {
285
+ if (!SUPPORT[args.support]) {
286
+ throw new AuthError("auth-passkey/bad-largeblob-support",
287
+ "extensions.largeBlob support must be 'preferred' or 'required'");
288
+ }
289
+ out.largeBlob.support = args.support;
290
+ modes++;
291
+ }
292
+ if (args.read === true) {
293
+ out.largeBlob.read = true;
294
+ modes++;
295
+ } else if (args.read !== undefined && args.read !== false) {
296
+ throw new AuthError("auth-passkey/bad-largeblob-read",
297
+ "extensions.largeBlob read must be a boolean");
298
+ }
299
+ if (args.write !== undefined && args.write !== null) {
300
+ if (!Buffer.isBuffer(args.write) && !(args.write instanceof Uint8Array)) {
301
+ throw new AuthError("auth-passkey/bad-largeblob-write",
302
+ "extensions.largeBlob write must be a Uint8Array / Buffer");
303
+ }
304
+ out.largeBlob.write = Buffer.from(args.write).toString("base64url");
305
+ modes++;
306
+ }
307
+ if (modes === 0) {
308
+ throw new AuthError("auth-passkey/empty-largeblob",
309
+ "extensions.largeBlob({}) needs support, read, or write");
310
+ }
311
+ if (args.read === true && args.write !== undefined && args.write !== null) {
312
+ throw new AuthError("auth-passkey/conflicting-largeblob",
313
+ "extensions.largeBlob — read and write are mutually exclusive");
314
+ }
315
+ return out;
316
+ }
317
+
318
+ // credBlob extension — WebAuthn L3 §10.5.
319
+ // Server-supplied opaque blob (≤32 bytes per CTAP2.1) bound to the
320
+ // credential at registration. Returned in subsequent assertions.
321
+ // Shape: `{ credBlob: <base64url> }`.
322
+ function _credBlobExt(args) {
323
+ if (!args || args.blob === undefined || args.blob === null) {
324
+ throw new AuthError("auth-passkey/missing-credblob",
325
+ "extensions.credBlob({ blob }) is required");
326
+ }
327
+ var buf;
328
+ if (Buffer.isBuffer(args.blob)) {
329
+ buf = args.blob;
330
+ } else if (args.blob instanceof Uint8Array) {
331
+ buf = Buffer.from(args.blob);
332
+ } else {
333
+ throw new AuthError("auth-passkey/bad-credblob",
334
+ "extensions.credBlob blob must be a Uint8Array / Buffer");
335
+ }
336
+ if (buf.length === 0 || buf.length > 32) { // allow:raw-byte-literal — CTAP2.1 §11.1 credBlob max
337
+ throw new AuthError("auth-passkey/credblob-bad-length",
338
+ "extensions.credBlob blob must be 1-32 bytes (CTAP2.1 §11.1)");
339
+ }
340
+ return { credBlob: buf.toString("base64url") };
341
+ }
342
+
343
+ var extensions = {
344
+ prf: _prfExt,
345
+ largeBlob: _largeBlobExt,
346
+ credBlob: _credBlobExt,
347
+ };
348
+
154
349
  async function verifyAuthentication(opts) {
155
350
  if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
156
351
  if (!opts.response) {
@@ -164,7 +359,7 @@ async function verifyAuthentication(opts) {
164
359
  "opts.credential { id, publicKey, counter? } is required");
165
360
  }
166
361
 
167
- return await _vendor().verifyAuthenticationResponse({
362
+ var rv = await _vendor().verifyAuthenticationResponse({
168
363
  response: opts.response,
169
364
  expectedChallenge: opts.expectedChallenge,
170
365
  expectedOrigin: opts.expectedOrigin,
@@ -177,6 +372,21 @@ async function verifyAuthentication(opts) {
177
372
  },
178
373
  requireUserVerification: opts.requireUserVerification !== false,
179
374
  });
375
+ // WebAuthn L3 §6.1.3 — same BE/BS surfacing as verifyRegistration.
376
+ // Authentication assertions also carry the BE/BS bits in authData; a
377
+ // credential that registered as single-device but later asserts as
378
+ // multi-device (or vice versa) is a backup-state-changed signal worth
379
+ // auditing at the operator level. We expose the current values so the
380
+ // caller can compare against what they persisted at registration.
381
+ if (rv && rv.authenticationInfo) {
382
+ rv.backupEligible = rv.authenticationInfo.credentialDeviceType === "multiDevice";
383
+ rv.backupState = rv.authenticationInfo.credentialBackedUp === true;
384
+ } else {
385
+ rv = rv || {};
386
+ rv.backupEligible = false;
387
+ rv.backupState = false;
388
+ }
389
+ return rv;
180
390
  }
181
391
 
182
392
  // ---- WebAuthn Signal API (W3C draft, 2024) ----
@@ -265,6 +475,8 @@ module.exports = {
265
475
  verifyRegistration: verifyRegistration,
266
476
  startAuthentication: startAuthentication,
267
477
  verifyAuthentication: verifyAuthentication,
478
+ conditionalAuthOptions: conditionalAuthOptions,
479
+ extensions: extensions,
268
480
  signalUnknownCredential: signalUnknownCredential,
269
481
  signalAllAcceptedCredentials: signalAllAcceptedCredentials,
270
482
  signalCurrentUserDetails: signalCurrentUserDetails,
@@ -136,7 +136,7 @@ function _defaultKeyExtractor(req) {
136
136
  * @signature b.authBotChallenge.create(opts)
137
137
  * @since 0.8.48
138
138
  * @status stable
139
- * @related b.middleware.botGuard, b.auth.lockout, b.auth.atoKillSwitch
139
+ * @related b.middleware.botGuard
140
140
  *
141
141
  * Build an adaptive bot-challenge gate. Returns
142
142
  * `{ middleware, recordFailure, recordSuccess, check, reset }`.
@@ -173,7 +173,7 @@ function _decodeEnvelope(env) {
173
173
  * @since 0.2.28
174
174
  * @status stable
175
175
  * @compliance pci-dss, soc2, hipaa
176
- * @related b.credentialHash.verify, b.credentialHash.needsRehash, b.auth.password.hash
176
+ * @related b.credentialHash.verify, b.credentialHash.needsRehash
177
177
  *
178
178
  * Hash a credential secret into a base64 envelope ready for storage in
179
179
  * a `credentialHash` column. Default algorithm is SHAKE256 with a
@@ -345,7 +345,7 @@ function inspect(envelope) {
345
345
  * @signature b.credentialHash.needsRehash(envelope, opts?)
346
346
  * @since 0.2.28
347
347
  * @status stable
348
- * @related b.credentialHash.hash, b.credentialHash.verify, b.auth.password.needsRehash
348
+ * @related b.credentialHash.hash, b.credentialHash.verify
349
349
  *
350
350
  * Returns `true` when the stored envelope was produced under an
351
351
  * algorithm or parameter set that no longer matches the framework
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.db.collection
4
+ * @nav Data
5
+ * @title Collection
6
+ * @order 210
7
+ * @card Mongo-style facade over `b.db.from(name)`. Wraps the
8
+ * chainable Query builder in `{ insert, find, findOne,
9
+ * update, remove, count, paginate }` for codebases
10
+ * migrating from MongoDB or for primitives that prefer
11
+ * the document-store call shape.
12
+ *
13
+ * @intro
14
+ * `b.db.collection(name)` returns a small adapter that maps Mongo-
15
+ * shape calls onto the framework's query-builder primitives:
16
+ *
17
+ * b.db.collection("users").findOne({ email: "alice@x.com" });
18
+ * → b.db.from("users").where({ email: "alice@x.com" }).first();
19
+ *
20
+ * b.db.collection("users").update({ _id }, { $set: { name } });
21
+ * → b.db.from("users").where({ _id }).updateOne({ name });
22
+ *
23
+ * b.db.collection("users").update({ _id }, { $inc: { failed: 1 } });
24
+ * → b.db.from("users").where({ _id }).increment("failed", 1);
25
+ *
26
+ * Operators migrating from a Mongo-shaped codebase (HermitStash and
27
+ * peers) can drop in this facade without rewriting every call site
28
+ * to the chainable builder. New code typically reaches for the
29
+ * builder directly — `b.db.from(...)` is more expressive and
30
+ * doesn't pretend to be Mongo.
31
+ *
32
+ * Supported update operators: `$set` (assign), `$inc` (atomic
33
+ * increment per column — composes `Query.increment`), `$unset`
34
+ * (set to NULL).
35
+ */
36
+
37
+ var lazyRequire = require("./lazy-require");
38
+
39
+ // db.js → db-collection.js → db.from() would create a require cycle.
40
+ // Defer the lookup to call-time so the binding lands after both
41
+ // modules finish loading.
42
+ var db = lazyRequire(function () { return require("./db"); });
43
+
44
+ function _validateQueryShape(query) {
45
+ if (!query || typeof query !== "object" || Array.isArray(query)) {
46
+ throw new TypeError("collection: query must be a plain object");
47
+ }
48
+ }
49
+
50
+ function _applyQuery(builder, query) {
51
+ // Mongo-shape supports `field: value` for equality and `field:
52
+ // { $gt: x }` / `{ $lt: x }` / `{ $gte: x }` / `{ $lte: x }` /
53
+ // `{ $ne: x }` / `{ $in: [...] }` / `{ $like: "pattern" }` for
54
+ // operators. Anything else throws — refuse silently translating
55
+ // unknown operators into something that might match more rows
56
+ // than intended.
57
+ var keys = Object.keys(query);
58
+ for (var i = 0; i < keys.length; i += 1) {
59
+ var k = keys[i];
60
+ var v = query[k];
61
+ if (v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date)) {
62
+ var opKeys = Object.keys(v);
63
+ for (var j = 0; j < opKeys.length; j += 1) {
64
+ var op = opKeys[j];
65
+ var val = v[op];
66
+ switch (op) {
67
+ case "$eq": builder.where(k, "=", val); break;
68
+ case "$ne": builder.where(k, "!=", val); break;
69
+ case "$gt": builder.where(k, ">", val); break;
70
+ case "$gte": builder.where(k, ">=", val); break;
71
+ case "$lt": builder.where(k, "<", val); break;
72
+ case "$lte": builder.where(k, "<=", val); break;
73
+ case "$in":
74
+ if (!Array.isArray(val)) {
75
+ throw new TypeError("collection: $in requires an array (got " + typeof val + ")");
76
+ }
77
+ builder.where(k, "IN", val);
78
+ break;
79
+ case "$like":
80
+ if (typeof val !== "string") {
81
+ throw new TypeError("collection: $like requires a string");
82
+ }
83
+ builder.where(k, "LIKE", val);
84
+ break;
85
+ default:
86
+ throw new TypeError("collection: unsupported query operator '" + op +
87
+ "' on field '" + k + "' (allowed: $eq / $ne / $gt / $gte / $lt / $lte / $in / $like)");
88
+ }
89
+ }
90
+ } else {
91
+ builder.where(k, "=", v);
92
+ }
93
+ }
94
+ }
95
+
96
+ function _splitUpdateOperators(update) {
97
+ // Allow either Mongo-shape `{ $set: {...}, $inc: {...} }` OR plain
98
+ // `{ field: value, ... }` (treated as $set). Returns a tuple of
99
+ // sets / increments / unsets so the caller can dispatch.
100
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
101
+ throw new TypeError("collection: update must be a plain object");
102
+ }
103
+ var keys = Object.keys(update);
104
+ var hasOperator = keys.some(function (k) { return k.charAt(0) === "$"; });
105
+ if (!hasOperator) {
106
+ return { sets: update, incs: null, unsets: null };
107
+ }
108
+ var sets = null;
109
+ var incs = null;
110
+ var unsets = null;
111
+ for (var i = 0; i < keys.length; i += 1) {
112
+ var k = keys[i];
113
+ if (k === "$set") {
114
+ if (!update[k] || typeof update[k] !== "object") {
115
+ throw new TypeError("collection: $set value must be an object");
116
+ }
117
+ sets = update[k];
118
+ } else if (k === "$inc") {
119
+ if (!update[k] || typeof update[k] !== "object") {
120
+ throw new TypeError("collection: $inc value must be an object");
121
+ }
122
+ incs = update[k];
123
+ } else if (k === "$unset") {
124
+ if (!update[k] || typeof update[k] !== "object") {
125
+ throw new TypeError("collection: $unset value must be an object");
126
+ }
127
+ unsets = update[k];
128
+ } else {
129
+ throw new TypeError("collection: unsupported update operator '" + k +
130
+ "' (allowed: $set / $inc / $unset; or pass a plain object for an implicit $set)");
131
+ }
132
+ }
133
+ return { sets: sets, incs: incs, unsets: unsets };
134
+ }
135
+
136
+ /**
137
+ * @primitive b.db.collection
138
+ * @signature b.db.collection(name)
139
+ * @since 0.8.58
140
+ * @status stable
141
+ * @related b.db.from, b.db
142
+ *
143
+ * Returns a Mongo-style adapter for the named table. Each method
144
+ * dispatches to `b.db.from(name)` under the hood; sealed-column
145
+ * semantics, derived-hash translation, and audit emission carry
146
+ * through unchanged.
147
+ *
148
+ * @example
149
+ * var b = require("@blamejs/core");
150
+ * await b.db.init({ dataDir: "/tmp/data", schema: [{
151
+ * name: "users",
152
+ * columns: { _id: "TEXT PRIMARY KEY", email: "TEXT", failed: "INTEGER NOT NULL DEFAULT 0" },
153
+ * }] });
154
+ * var users = b.db.collection("users");
155
+ * users.insert({ _id: "u1", email: "alice@x.com" });
156
+ * users.findOne({ email: "alice@x.com" });
157
+ * users.update({ _id: "u1" }, { $inc: { failed: 1 } });
158
+ * users.update({ _id: "u1" }, { $set: { failed: 0 } });
159
+ * users.remove({ _id: "u1" });
160
+ */
161
+ function collection(name) {
162
+ if (typeof name !== "string" || name.length === 0) {
163
+ throw new TypeError("collection(name): name must be a non-empty string");
164
+ }
165
+ return {
166
+ name: name,
167
+
168
+ // Insert one document. Returns the inserted row with `_id` filled
169
+ // in (if absent on input). Composes Query.insertOne.
170
+ insert: function (doc) {
171
+ return db().from(name).insertOne(doc);
172
+ },
173
+
174
+ // Insert many. Returns array of inserted rows.
175
+ insertMany: function (docs) {
176
+ return db().from(name).insertMany(docs);
177
+ },
178
+
179
+ // Find rows matching the query. Returns an array. Pass `opts.limit`
180
+ // / `opts.offset` / `opts.orderBy` / `opts.orderDir` for paging.
181
+ find: function (query, opts) {
182
+ _validateQueryShape(query || {});
183
+ var q = db().from(name);
184
+ _applyQuery(q, query || {});
185
+ if (opts && opts.orderBy) q.orderBy(opts.orderBy, opts.orderDir || "asc");
186
+ if (opts && opts.limit !== undefined) q.limit(opts.limit);
187
+ if (opts && opts.offset !== undefined) q.offset(opts.offset);
188
+ return q.all();
189
+ },
190
+
191
+ // Find one row, or null. Equivalent to `.find(...).all()[0]` but
192
+ // emits `LIMIT 1` so the engine doesn't materialise the rest.
193
+ findOne: function (query) {
194
+ _validateQueryShape(query);
195
+ var q = db().from(name);
196
+ _applyQuery(q, query);
197
+ return q.first() || null;
198
+ },
199
+
200
+ // Update rows matching the query. Accepts Mongo `{ $set, $inc,
201
+ // $unset }` operator form OR a plain field-map (treated as $set).
202
+ // Returns the number of rows changed.
203
+ //
204
+ // `$inc` composes Query.increment so the SQL is
205
+ // UPDATE table SET col = COALESCE(col, 0) + ? WHERE ...
206
+ // — atomic across concurrent writers, no fetch/mutate/store race.
207
+ update: function (query, update, opts) {
208
+ _validateQueryShape(query || {});
209
+ var split = _splitUpdateOperators(update);
210
+ var single = !(opts && opts.many === true);
211
+ var changed = 0;
212
+
213
+ // $inc — apply increments per column. Each call shares the
214
+ // where-clause but is its own UPDATE statement (one SQL per
215
+ // bumped column). The where filter must be re-built per call
216
+ // because Query is single-shot.
217
+ if (split.incs) {
218
+ var incCols = Object.keys(split.incs);
219
+ for (var i = 0; i < incCols.length; i += 1) {
220
+ var qInc = db().from(name);
221
+ _applyQuery(qInc, query || {});
222
+ var delta = split.incs[incCols[i]];
223
+ if (typeof delta !== "number" || !Number.isInteger(delta)) {
224
+ throw new TypeError("collection.update: $inc.'" + incCols[i] + "' must be an integer");
225
+ }
226
+ changed += qInc.increment(incCols[i], delta);
227
+ }
228
+ }
229
+
230
+ // $set / plain-object form — single UPDATE with the merged
231
+ // changes object.
232
+ var setObj = null;
233
+ if (split.sets) setObj = Object.assign({}, split.sets);
234
+ if (split.unsets) {
235
+ if (!setObj) setObj = {};
236
+ Object.keys(split.unsets).forEach(function (k) { setObj[k] = null; });
237
+ }
238
+ if (setObj && Object.keys(setObj).length > 0) {
239
+ var qSet = db().from(name);
240
+ _applyQuery(qSet, query || {});
241
+ if (single) {
242
+ changed += (qSet.updateOne(setObj) ? 1 : 0);
243
+ } else {
244
+ changed += qSet.updateMany(setObj);
245
+ }
246
+ }
247
+
248
+ return changed;
249
+ },
250
+
251
+ // Convenience — `updateMany(query, update)` shorthand for
252
+ // `update(query, update, { many: true })`.
253
+ updateMany: function (query, update) {
254
+ return this.update(query, update, { many: true });
255
+ },
256
+
257
+ // Remove rows matching the query. Returns the number of rows
258
+ // deleted. Default deletes ONE row; pass `{ many: true }` to
259
+ // delete all matches (matches the framework's `deleteMany` rule
260
+ // — no unconditional deletes).
261
+ remove: function (query, opts) {
262
+ _validateQueryShape(query || {});
263
+ var q = db().from(name);
264
+ _applyQuery(q, query || {});
265
+ if (opts && opts.many === true) {
266
+ return q.deleteMany();
267
+ }
268
+ return q.deleteOne() ? 1 : 0;
269
+ },
270
+
271
+ // Count rows matching the query.
272
+ count: function (query) {
273
+ _validateQueryShape(query || {});
274
+ var q = db().from(name);
275
+ _applyQuery(q, query || {});
276
+ return q.count();
277
+ },
278
+
279
+ // Paginate — `{ items, total, limit, offset, page, totalPages }`.
280
+ // Composes Query.paginate.
281
+ paginate: function (query, opts) {
282
+ _validateQueryShape(query || {});
283
+ var q = db().from(name);
284
+ _applyQuery(q, query || {});
285
+ return q.paginate(opts || {});
286
+ },
287
+ };
288
+ }
289
+
290
+ module.exports = { collection: collection };