@blamejs/core 0.14.11 → 0.14.13

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.
@@ -24,9 +24,9 @@
24
24
  * Format auto-detection sniffs the first ~512 bytes for magic
25
25
  * signatures: ZIP (LFH magic `0x04034b50` + EOCD magic `0x06054b50`),
26
26
  * tar (`ustar` at offset 257), gzip / tar.gz (RFC 1952 magic), and
27
- * `b.crypto.encryptPacked`-wrapped envelopes (auto-unwrapped before
28
- * format detection). Unrecognized inputs are flagged
29
- * `safe-archive/format-unsupported`.
27
+ * `b.archive.wrap` recipient (`BAWRP`) / passphrase (`BAWPP`)
28
+ * envelopes (auto-unwrapped before format detection). Unrecognized
29
+ * inputs are flagged `safe-archive/format-unsupported`.
30
30
  *
31
31
  * The orchestrator refuses the WHOLE archive on any single critical
32
32
  * guard issue — no partial extraction. Cleanup is `fs.rm`-recursive
@@ -58,13 +58,10 @@ var MAGIC_ZIP_LFH = 0x04034b50;
58
58
  var MAGIC_ZIP_EOCD = 0x06054b50;
59
59
  // GZIP magic per RFC 1952 §2.3.1.
60
60
  var MAGIC_GZIP_BE = 0x1f8b;
61
- // b.crypto.encryptPacked envelope magic — the prefix the framework's
62
- // PQ envelope writes. (Sentinel value for v0.12.10+ Flavor 1 unwrap.)
63
- var MAGIC_ENCPACKED = "EPACK";
64
61
 
65
62
  async function _sniffMagic(adapter) {
66
63
  // For random-access adapters, the format sniffer reads the first
67
- // 512 bytes — enough for ZIP + GZIP + b.crypto.encryptPacked magic
64
+ // 512 bytes — enough for ZIP + GZIP + wrap-envelope magic
68
65
  // detection. tar magic lives at offset 257 inside the first 512-
69
66
  // byte header block, so we need at least 263 bytes; 512 covers it.
70
67
  if (adapter.kind !== "random-access") {
@@ -91,13 +88,13 @@ async function _sniffMagic(adapter) {
91
88
  var be2 = head.readUInt16BE(0);
92
89
  if (be2 === MAGIC_GZIP_BE) return { format: "gzip" };
93
90
  }
94
- // b.crypto.encryptPacked — 5-byte ASCII prefix.
91
+ // archive-wrap envelopes — 5-byte ASCII prefix. BAWRP (recipient) and
92
+ // BAWPP (passphrase) are the only wrap envelopes the framework produces
93
+ // (b.archive.wrap / b.archive.wrapWithPassphrase) and the only ones
94
+ // b.archive.sniffEnvelope recognizes.
95
95
  if (head.length >= 5) {
96
96
  var prefix = head.slice(0, 5).toString("utf8");
97
- if (prefix === MAGIC_ENCPACKED) return { format: "encryptPacked" };
98
- // v0.12.15 — archive-wrap recipient envelope (v0.12.10 / BAWRP).
99
97
  if (prefix === "BAWRP") return { format: "wrap-recipient" };
100
- // v0.12.15 — archive-wrap passphrase envelope (v0.12.11 / BAWPP).
101
98
  if (prefix === "BAWPP") return { format: "wrap-passphrase" };
102
99
  }
103
100
  // tar — "ustar" at offset 257 within the first 512-byte header.
@@ -124,6 +121,82 @@ async function _collectSourceBytes(source) {
124
121
  return source.range(0, size);
125
122
  }
126
123
 
124
+ // Shared source→adapter resolution + envelope auto-unwrap for the three
125
+ // orchestrator entry points (extract / extractToMemory / inspect). Returns
126
+ // { source, format } — `source` is a random-access adapter positioned at the
127
+ // (possibly unwrapped) archive and `format` is the sniffed inner format. The
128
+ // CALLER owns closing the returned source in its own `finally`; this helper
129
+ // performs the pre-unwrap fd-close-before-replace + the signal-forward-to-
130
+ // inner-adapter discipline internally, and closes a string-opened descriptor
131
+ // if it throws mid-resolve so a sniff/unwrap failure can't leak it.
132
+ async function _resolveAndUnwrap(opts, label, refuseTrustedStream) {
133
+ var openedFromString = typeof opts.source === "string";
134
+ var source = opts.source;
135
+ if (openedFromString) {
136
+ source = archiveAdapters().fs(source, { signal: opts.signal });
137
+ } else if (Buffer.isBuffer(source)) {
138
+ source = archiveAdapters().buffer(source, { signal: opts.signal });
139
+ } else if (refuseTrustedStream && archiveAdapters().isTrustedStreamAdapter(source)) {
140
+ // Trusted-stream adapters satisfy the adapter contract, but the
141
+ // orchestrator's adversarial-safe central-directory walk + LFH/CD skew
142
+ // defense needs random access. Refuse upfront with a typed error so the
143
+ // operator sees the constraint at the entry point rather than a
144
+ // downstream `archive-read/wrong-entry-point`.
145
+ throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
146
+ label + ": trusted-stream adapter sources are not supported by the orchestrator " +
147
+ "(the adversarial-safe central-directory walk requires random access). Collect the " +
148
+ "bytes into a buffer adapter — `b.archive.adapters.buffer(await collect(readable))` — " +
149
+ "and pass that, or read with `b.archive.read.zip.fromTrustedStream` directly.");
150
+ } else if (!archiveAdapters().isRandomAccessAdapter(source)) {
151
+ throw new SafeArchiveError("safe-archive/bad-source",
152
+ label + ": opts.source must be a string path, Buffer, or b.archive.adapters.* result");
153
+ }
154
+ try {
155
+ var format = opts.format || "auto";
156
+ if (format === "auto") {
157
+ format = (await _sniffMagic(source)).format;
158
+ }
159
+ // Auto-unwrap path: when the sniffer identifies a wrap envelope, unwrap
160
+ // inline + re-sniff the inner bytes so operators get a single call
161
+ // regardless of envelope shape. Operator supplies opts.recipient or
162
+ // opts.passphrase matching the envelope kind.
163
+ if (format === "wrap-recipient" || format === "wrap-passphrase") {
164
+ var sealedBytes = await _collectSourceBytes(source);
165
+ var inner;
166
+ if (format === "wrap-recipient") {
167
+ if (!opts.recipient) {
168
+ throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
169
+ label + ": source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
170
+ "Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
171
+ }
172
+ inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
173
+ } else {
174
+ if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
175
+ throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
176
+ label + ": source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
177
+ "Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
178
+ }
179
+ inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
180
+ }
181
+ // Close the original string-opened descriptor BEFORE replacing the
182
+ // source reference (overwriting it would leak the fd across repeated
183
+ // calls → EMFILE under load), then forward opts.signal to the inner
184
+ // buffer adapter so abort propagation survives the unwrap boundary.
185
+ if (typeof source.close === "function" && openedFromString) {
186
+ try { source.close(); } catch (_e) { /* drop-silent */ }
187
+ }
188
+ source = archiveAdapters().buffer(inner, { signal: opts.signal });
189
+ format = (await _sniffMagic(source)).format;
190
+ }
191
+ return { source: source, format: format };
192
+ } catch (e) {
193
+ if (typeof source.close === "function" && openedFromString) {
194
+ try { source.close(); } catch (_e2) { /* drop-silent */ }
195
+ }
196
+ throw e;
197
+ }
198
+ }
199
+
127
200
  // ---- Public extract orchestrator ----------------------------------------
128
201
 
129
202
  /**
@@ -168,84 +241,10 @@ async function extract(opts) {
168
241
  opts = opts || {};
169
242
  validateOpts.requireNonEmptyString(opts.destination,
170
243
  "b.safeArchive.extract: opts.destination", SafeArchiveError, "safe-archive/no-destination");
171
- // Resolve source adapter. Strings become fs adapters; Buffers
172
- // become buffer adapters; anything else is assumed to BE an adapter
173
- // already.
174
- var source = opts.source;
175
- if (typeof source === "string") {
176
- source = archiveAdapters().fs(source, { signal: opts.signal });
177
- } else if (Buffer.isBuffer(source)) {
178
- source = archiveAdapters().buffer(source, { signal: opts.signal });
179
- } else if (archiveAdapters().isTrustedStreamAdapter(source)) {
180
- // Trusted-stream adapters are accepted by the contract but the
181
- // orchestrator's extract path needs random-access (CD-walk +
182
- // LFH/CD skew defense). Refuse upfront with a typed safe-archive
183
- // error so the operator sees the constraint at the entry point
184
- // rather than an `archive-read/wrong-entry-point` thrown by the
185
- // downstream reader. Trusted-stream extract via
186
- // `b.archive.read.zip.fromTrustedStream` is deferred to v0.12.8
187
- // alongside the tar reader's sequential mode.
188
- throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
189
- "extract: trusted-stream adapter sources are not supported by the orchestrator " +
190
- "(the adversarial-safe CD-walk requires random-access). Collect the bytes via " +
191
- "`b.archive.adapters.buffer(await collect(readable))` and pass that, or use " +
192
- "`b.archive.read.zip.fromTrustedStream` directly when the v0.12.8 sequential " +
193
- "extract path lands");
194
- } else if (!archiveAdapters().isRandomAccessAdapter(source)) {
195
- throw new SafeArchiveError("safe-archive/bad-source",
196
- "extract: opts.source must be a string path, Buffer, or b.archive.adapters.* result");
197
- }
198
-
244
+ var resolved = await _resolveAndUnwrap(opts, "extract", true);
245
+ var source = resolved.source;
246
+ var format = resolved.format;
199
247
  try {
200
- var format = opts.format || "auto";
201
- if (format === "auto") {
202
- var sniff = await _sniffMagic(source);
203
- format = sniff.format;
204
- }
205
- // v0.12.15 — auto-unwrap path. When the sniffer identifies a
206
- // wrap envelope, unwrap inline + re-sniff the inner bytes so
207
- // operators get a single extract() call regardless of envelope
208
- // shape. Operator must supply opts.recipient or opts.passphrase
209
- // matching the envelope kind.
210
- if (format === "wrap-recipient" || format === "wrap-passphrase") {
211
- var sealedBytes = await _collectSourceBytes(source);
212
- var inner;
213
- if (format === "wrap-recipient") {
214
- if (!opts.recipient) {
215
- throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
216
- "extract: source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
217
- "Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
218
- }
219
- inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
220
- } else {
221
- if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
222
- throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
223
- "extract: source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
224
- "Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
225
- }
226
- inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
227
- }
228
- // Close the original source
229
- // adapter BEFORE replacing it. When opts.source was a string
230
- // path, the fs adapter opened a file descriptor; overwriting
231
- // `source` loses the close reference and the descriptor
232
- // leaks across repeated extract() calls (eventually EMFILE
233
- // under load). The outer finally still closes whatever
234
- // `source` points at, but the original handle needs explicit
235
- // release here.
236
- if (typeof source.close === "function" && typeof opts.source === "string") {
237
- try { source.close(); } catch (_e) { /* drop-silent */ }
238
- }
239
- // Forward opts.signal to the
240
- // inner buffer adapter so abort propagation stays intact
241
- // across the unwrap boundary. Without it, an abort raised
242
- // after unwrapping would no longer cancel inner range()
243
- // calls, breaking the documented signal contract for
244
- // large wrapped archives.
245
- source = archiveAdapters().buffer(inner, { signal: opts.signal });
246
- var innerSniff = await _sniffMagic(source);
247
- format = innerSniff.format;
248
- }
249
248
  var reader;
250
249
  if (format === "zip") {
251
250
  reader = archiveRead().zip(source, {
@@ -277,7 +276,7 @@ async function extract(opts) {
277
276
  } else {
278
277
  throw new SafeArchiveError("safe-archive/format-unsupported",
279
278
  "extract: format=" + JSON.stringify(format) + " — supported formats are " +
280
- "zip, tar, tar.gz; b.crypto.encryptPacked-wrapped archives are auto-unwrapped first");
279
+ "zip, tar, tar.gz; b.archive.wrap recipient/passphrase envelopes are auto-unwrapped first");
281
280
  }
282
281
  var result = await reader.extract({
283
282
  destination: opts.destination,
@@ -291,6 +290,109 @@ async function extract(opts) {
291
290
  }
292
291
  }
293
292
 
293
+ /**
294
+ * @primitive b.safeArchive.extractToMemory
295
+ * @signature b.safeArchive.extractToMemory(opts)
296
+ * @since 0.14.13
297
+ * @status stable
298
+ * @compliance hipaa, pci-dss, gdpr, soc2
299
+ * @related b.safeArchive.extract, b.archive.read.zip, b.archive.read.tar
300
+ *
301
+ * In-memory counterpart to `b.safeArchive.extract` for read-only /
302
+ * serverless filesystems. Resolves the source, sniffs the format,
303
+ * auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and
304
+ * dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries`
305
+ * — an async generator that yields each regular file entry's decompressed
306
+ * bytes without ever writing to disk. Takes no `destination`; the caller
307
+ * owns where, if anywhere, the bytes land.
308
+ *
309
+ * Every defense the disk `extract` runs applies unchanged: the zip-bomb
310
+ * caps (entry-count / per-entry / total / expansion-ratio), the
311
+ * `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-
312
+ * escape / encrypted-entry refusal — CVE-2025-3445 class), and the
313
+ * entry-type policy. Directory entries carry no bytes and are skipped. The
314
+ * disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU
315
+ * defense) is intentionally absent — there is no extraction root — so the
316
+ * archive-level name refusals carry the containment guarantee here.
317
+ *
318
+ * Trusted-stream adapter sources are refused upfront: the adversarial-safe
319
+ * central-directory walk requires random access. Collect the bytes into a
320
+ * buffer adapter, or read with `b.archive.read.zip.fromTrustedStream`
321
+ * directly.
322
+ *
323
+ * @opts
324
+ * source: b.archive.adapters.* | Buffer | string,
325
+ * format: "auto" | "zip" | "tar" | "tar.gz",
326
+ * bombPolicy: b.guardArchive.zipBombPolicy(...) | { ... },
327
+ * entryTypePolicy: b.guardArchive.entryTypePolicy(...) | { ... },
328
+ * guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
329
+ * recipient: { privateKey, ecPrivateKey }, // for BAWRP envelopes
330
+ * passphrase: string | Buffer, // for BAWPP envelopes
331
+ * audit: b.audit,
332
+ * signal: AbortSignal,
333
+ *
334
+ * @example
335
+ * for await (var entry of b.safeArchive.extractToMemory({
336
+ * source: b.archive.adapters.fs("/var/uploads/payload.zip"),
337
+ * guardProfile: "strict",
338
+ * })) {
339
+ * // entry → { name, bytes, size } — never touches disk
340
+ * await store.put(entry.name, entry.bytes);
341
+ * }
342
+ */
343
+ async function* extractToMemory(opts) {
344
+ opts = opts || {};
345
+ var resolved = await _resolveAndUnwrap(opts, "extractToMemory", true);
346
+ var source = resolved.source;
347
+ var format = resolved.format;
348
+ var extractOpts = { allowDangerous: opts.allowDangerous, allowEncrypted: opts.allowEncrypted };
349
+ try {
350
+ if (format === "zip") {
351
+ var zr = archiveRead().zip(source, {
352
+ bombPolicy: opts.bombPolicy,
353
+ entryTypePolicy: opts.entryTypePolicy,
354
+ guardProfile: opts.guardProfile,
355
+ audit: opts.audit,
356
+ });
357
+ for await (var ze of zr.extractEntries(extractOpts)) { yield ze; }
358
+ } else if (format === "tar") {
359
+ var tr = archiveTarRead().tar(source, {
360
+ bombPolicy: opts.bombPolicy,
361
+ entryTypePolicy: opts.entryTypePolicy,
362
+ guardProfile: opts.guardProfile,
363
+ audit: opts.audit,
364
+ });
365
+ for await (var te of tr.extractEntries(extractOpts)) { yield te; }
366
+ } else if (format === "tar.gz") {
367
+ // The gz reader's asTar() shim exposes inspect + extract but NOT
368
+ // extractEntries, so materialize the gz layer to a Buffer (the gz
369
+ // bomb caps still run during toBuffer()) and walk a fresh tar reader
370
+ // over it — the tar bomb / guard / entry-type caps run on the inner
371
+ // walk, so no defense is dropped.
372
+ var tarBytes = await archiveGz().read.gz(source, {
373
+ maxDecompressedBytes: opts.maxDecompressedBytes,
374
+ maxExpansionRatio: opts.maxExpansionRatio,
375
+ audit: opts.audit,
376
+ }).toBuffer();
377
+ var gtr = archiveTarRead().tar(archiveAdapters().buffer(tarBytes, { signal: opts.signal }), {
378
+ bombPolicy: opts.bombPolicy,
379
+ entryTypePolicy: opts.entryTypePolicy,
380
+ guardProfile: opts.guardProfile,
381
+ audit: opts.audit,
382
+ });
383
+ for await (var ge of gtr.extractEntries(extractOpts)) { yield ge; }
384
+ } else {
385
+ throw new SafeArchiveError("safe-archive/format-unsupported",
386
+ "extractToMemory: format=" + JSON.stringify(format) + " — supported formats are " +
387
+ "zip, tar, tar.gz; b.archive.wrap recipient/passphrase envelopes are auto-unwrapped first");
388
+ }
389
+ } finally {
390
+ if (typeof source.close === "function" && typeof opts.source === "string") {
391
+ try { source.close(); } catch (_e) { /* drop-silent */ }
392
+ }
393
+ }
394
+ }
395
+
294
396
  /**
295
397
  * @primitive b.safeArchive.inspect
296
398
  * @signature b.safeArchive.inspect(opts)
@@ -316,53 +418,10 @@ async function extract(opts) {
316
418
  */
317
419
  async function inspect(opts) {
318
420
  opts = opts || {};
319
- var source = opts.source;
320
- if (typeof source === "string") {
321
- source = archiveAdapters().fs(source, { signal: opts.signal });
322
- } else if (Buffer.isBuffer(source)) {
323
- source = archiveAdapters().buffer(source, { signal: opts.signal });
324
- } else if (!archiveAdapters().isRandomAccessAdapter(source)) {
325
- throw new SafeArchiveError("safe-archive/bad-source",
326
- "inspect: opts.source must be a string path, Buffer, or random-access adapter");
327
- }
421
+ var resolved = await _resolveAndUnwrap(opts, "inspect", false);
422
+ var source = resolved.source;
423
+ var format = resolved.format;
328
424
  try {
329
- var format = opts.format || "auto";
330
- if (format === "auto") {
331
- var sniff = await _sniffMagic(source);
332
- format = sniff.format;
333
- }
334
- // v0.12.16 — auto-unwrap path for inspect, parallel to the
335
- // v0.12.15 extract path. Wrap envelopes (BAWRP / BAWPP) are
336
- // unwrapped inline + re-sniffed so operators can enumerate
337
- // entries of a sealed archive in a single inspect() call.
338
- if (format === "wrap-recipient" || format === "wrap-passphrase") {
339
- var sealedBytes = await _collectSourceBytes(source);
340
- var inner;
341
- if (format === "wrap-recipient") {
342
- if (!opts.recipient) {
343
- throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
344
- "inspect: source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
345
- "Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
346
- }
347
- inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
348
- } else {
349
- if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
350
- throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
351
- "inspect: source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
352
- "Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
353
- }
354
- inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
355
- }
356
- // v0.12.15 P1 — close the original fs adapter (if string-
357
- // backed) BEFORE replacing the source reference. v0.12.15 P2
358
- // — forward opts.signal to the inner buffer adapter.
359
- if (typeof source.close === "function" && typeof opts.source === "string") {
360
- try { source.close(); } catch (_e) { /* drop-silent */ }
361
- }
362
- source = archiveAdapters().buffer(inner, { signal: opts.signal });
363
- var innerSniff = await _sniffMagic(source);
364
- format = innerSniff.format;
365
- }
366
425
  var reader;
367
426
  if (format === "zip") {
368
427
  reader = archiveRead().zip(source, {
@@ -388,7 +447,7 @@ async function inspect(opts) {
388
447
  });
389
448
  } else {
390
449
  throw new SafeArchiveError("safe-archive/format-unsupported",
391
- "inspect: format=" + JSON.stringify(format) + " — v0.12.19 ships ZIP + tar + tar.gz; auto-unwraps wrap envelopes");
450
+ "inspect: format=" + JSON.stringify(format) + " — supported formats are zip, tar, tar.gz; wrap envelopes are auto-unwrapped first");
392
451
  }
393
452
  var entries = await reader.inspect();
394
453
  var totalCompressed = 0;
@@ -412,6 +471,7 @@ async function inspect(opts) {
412
471
 
413
472
  module.exports = {
414
473
  extract: extract,
474
+ extractToMemory: extractToMemory,
415
475
  inspect: inspect,
416
476
  SafeArchiveError: SafeArchiveError,
417
477
  // Exposed for tests + sibling modules.
@@ -190,6 +190,29 @@ function requireObject(opts, callerLabel, errorClass, code) {
190
190
  return opts;
191
191
  }
192
192
 
193
+ // requireMethods — validate an injected dependency exposes the named
194
+ // methods. Collapses the repeated `if (!obj || typeof obj.fn !==
195
+ // "function" || ...) throw` injected-store / exporter / backend guards
196
+ // (b.agent.*.reseal stores, b.dsr / b.outbox create() backends, etc.)
197
+ // into one definition. Throws on null / non-object / any missing-or-
198
+ // non-function method; returns obj on success.
199
+ function requireMethods(obj, methods, callerLabel, errorClass, code) {
200
+ var label = callerLabel || "dependency";
201
+ if (!obj || typeof obj !== "object") {
202
+ _throw(errorClass, code, label + " must be an object exposing { " +
203
+ methods.join(", ") + " }, got " + (obj === null ? "null" : typeof obj),
204
+ "validate-opts/bad-methods-object");
205
+ }
206
+ for (var i = 0; i < methods.length; i += 1) {
207
+ if (typeof obj[methods[i]] !== "function") {
208
+ _throw(errorClass, code, label + " must expose a " + methods[i] +
209
+ "() method (requires { " + methods.join(", ") + " })",
210
+ "validate-opts/missing-method");
211
+ }
212
+ }
213
+ return obj;
214
+ }
215
+
193
216
  function optionalNonEmptyString(value, label, errorClass, code) {
194
217
  if (value === undefined || value === null) return value;
195
218
  if (typeof value !== "string" || value.length === 0) {
@@ -376,6 +399,7 @@ module.exports.optionalPlainObject = optionalPlainObject;
376
399
  module.exports.requireNonEmptyString = requireNonEmptyString;
377
400
  module.exports.observabilityShape = observabilityShape;
378
401
  module.exports.requireObject = requireObject;
402
+ module.exports.requireMethods = requireMethods;
379
403
  module.exports.applyDefaults = applyDefaults;
380
404
  module.exports.makeAuditEmitter = makeAuditEmitter;
381
405
  module.exports.makeNamespacedEmitters = makeNamespacedEmitters;