@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.
- package/CHANGELOG.md +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/mtls-ca.js +15 -5
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/auth/passkey.js
CHANGED
|
@@ -121,20 +121,55 @@ async function verifyRegistration(opts) {
|
|
|
121
121
|
_requireString(opts.expectedOrigin, "expectedOrigin");
|
|
122
122
|
_requireString(opts.expectedRPID, "expectedRPID");
|
|
123
123
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
139
|
+
* @related b.middleware.botGuard
|
|
140
140
|
*
|
|
141
141
|
* Build an adaptive bot-challenge gate. Returns
|
|
142
142
|
* `{ middleware, recordFailure, recordSuccess, check, reset }`.
|
package/lib/credential-hash.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|