@blamejs/core 0.9.43 → 0.9.45
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 +2 -0
- package/lib/agent-tenant.js +168 -0
- package/lib/argon2-builtin.js +8 -1
- package/lib/auth/dpop.js +2 -7
- package/lib/auth/jwt.js +3 -7
- package/lib/auth/oauth.js +4 -8
- package/lib/auth/status-list.js +3 -8
- package/lib/crypto.js +61 -0
- package/lib/network-dns-resolver.js +2 -1
- package/lib/network-dns.js +4 -3
- package/lib/object-store/gcs.js +2 -6
- package/lib/pagination.js +2 -7
- package/lib/storage.js +417 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.45 (2026-05-15) — **`b.crypto.toBase64Url` / `fromBase64Url` helpers + lib-wide `.replace(/X+$/, ...)` ReDoS-shape sweep.** The trailing-greedy regex `.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")` base64url-by-hand pattern was duplicated across 9 framework call sites (JWT / DPoP / OAuth / SD-JWT VC status-list / DNS-over-HTTPS GET encoding ×3 / GCS service-account JWT signing / pagination cursors). The trailing `/=+$/` regex is polynomial-ReDoS-shaped per CodeQL `js/polynomial-redos` — the engine backtracks on inputs with many trailing `=`. (1) **`b.crypto.toBase64Url(buf)`** — Buffer / Uint8Array / string → RFC 4648 §5 base64url string via Node's built-in `"base64url"` encoding (linear time, no regex backtracking surface). (2) **`b.crypto.fromBase64Url(s)`** — inverse decode. (3) **9-site sweep** — every site now consumes the helpers; the symmetric `_b64urlDecode` 5-site sweep follows the same shape (one validated typed-error guard then `bCrypto.fromBase64Url`). `lib/argon2-builtin.js` retains its own `_b64NoPad` helper (PHC strings use standard base64 alphabet `+/` not url-safe `-_`); converted from `.replace(/=+$/, "")` to a linear `charCodeAt`+`slice` loop. (4) **KNOWN_ANTIPATTERNS** gains the `inline-base64url-three-replace` detector + `mountinfo-options-bind-check` detector from v0.9.43 — any future site that reaches for either pattern trips the gate at n=1. (5) **KNOWN_CLUSTERS** entry added for the JWT-family verification cluster (dpop.verify / jwt._requireNumericDate / oauth.verifyBackchannelLogoutToken) that surfaced after the redos sweep shifted line offsets; structurally distinct RFC primitives (RFC 9449 DPoP / RFC 7519 JWT / OIDC Back-Channel Logout) sharing a replayStore.checkAndInsert + numeric-date-bound shingle. References: [RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648#section-5) (base64url encoding spec), [CodeQL js/polynomial-redos](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/) (the regex-engine backtracking class CodeQL flags).
|
|
12
|
+
- v0.9.44 (2026-05-15) — **Two downstream-consumer gap items bundled: `b.storage.chunkScratch` + `b.agent.tenant` cryptoField adoption helper.** Closes the third batch of gap-list items. **(A) `b.storage.chunkScratch`** — resumable-chunked-upload primitive. Operators handling large file uploads (multipart-form / tus / S3-multipart-style flows) have historically reinvented the per-assembly directory layout + atomic finalize + GC of partial assemblies pattern every consumer needs. `b.storage.chunkScratch(opts?)` owns it once. Returns a handle with 10 lifecycle methods. (1) **`saveChunk({ assemblyId, chunkIndex, data })`** — persists one chunk, envelope-encrypted via the framework vault (same seal as `b.storage.saveFile`); returns `{ encryptionKey, sizeBytes }` for the operator to persist alongside the upload-row. Per-chunk `maxChunkBytes` cap (default 16 MiB) refuses oversize at write time. (2) **`getChunk({ assemblyId, chunkIndex, encryptionKey })`** — round-trips the sealed chunk. (3) **`chunkExists({ assemblyId, chunkIndex })`** — boolean probe. (4) **`listChunks(assemblyId)`** — sorted array of chunk indices present. (5) **`countChunks(assemblyId)`** — count. (6) **`removeChunk({ assemblyId, chunkIndex })`** — single-chunk delete. (7) **`assemble({ assemblyId, expectedTotal?, chunkEncryptionKeys })`** — verifies monotonic 0..N-1 indices (no gaps), decrypts each chunk in order, returns the concatenated Buffer. Refuses on count mismatch with `expectedTotal` or any chunk-index gap. (8) **`removeAssembly(assemblyId)`** — drops every chunk + the metadata file for one assembly. (9) **`listAssemblies()`** — every assembly with at least one chunk. (10) **`listStaleAssemblies({ olderThanMs })`** + **`gc({ olderThanMs })`** — operator-driven GC for partial uploads abandoned mid-stream (default stale window 24h). `assemblyId` shape is validated to refuse path-traversal (`..`), slash / backslash, NUL / C0 / DEL, dot-prefix, and oversize (>128 bytes). Backend is the operator-configured `b.storage` backend (no new backend concept). Audit events: `system.storage.chunk_scratch.chunk_saved` / `assembled` / `removed` / `gc`. Composes the existing `b.storage.saveFile` envelope; no new crypto. Wire-protocol reference: tus.io v1.0.0, RFC 9110 §14.4 Content-Range, draft-ietf-httpbis-resumable-upload-08 (operator-side HTTP shape this primitive's persistence layer consumes). Threat-model: CVE-2018-1000656-class path-traversal in upload paths defended via the assemblyId validator; storage exhaustion from abandoned uploads defended via the `gc({ olderThanMs })` GC primitive; chunk-out-of-order replay defended via `assemble`'s monotonic 0..N-1 index check. **(B) `b.agent.tenant` cryptoField adoption helper** — `sealField(tenantId, table, field, plaintext)` / `unsealField(...)` / `sealRowForTenant(tenantId, table, row)` / `unsealRowForTenant(tenantId, table, row)`. `b.cryptoField.sealRow` uses the singleton vault keypair — every tenant's sealed data decrypts under the same framework key, which fails the cross-tenant cryptographic isolation that HIPAA §164.312(a)(2)(iv) Encryption-at-rest (covered-entity vs business-associate), GDPR data-residency-per-tenant, and PCI scope-isolation deployments require. The adoption helper derives a per-tenant 32-byte AEAD key via `b.crypto.namespaceHash("agent.tenant.derive.cryptoField:<table>", tenantId)` (NIST SP 800-108 r1 §5.1 KDF-in-Counter-mode shape using SHA3-512) and routes each sealed field through `b.crypto.encryptPacked` (XChaCha20-Poly1305 per draft-irtf-cfrg-xchacha-03; 24-byte nonce making random-nonce generation safe at framework scale) with AAD-bound context (`tenantId|table|field`) per RFC 8439 §2.5 so a ciphertext from tenant A literally cannot decrypt as tenant B's row — even with the wrong tenantId the Poly1305 tag check fails. Threat-model coverage: cross-tenant data exposure class (CVE-2019-19528 was an early multi-tenant example where shared encryption keys allowed cross-tenant decrypt with DB access; this primitive's AAD-binding + per-tenant key derivation defends the class by construction). Ciphertext shape: `"tnt-v1:" + base64(packed)`, distinguishable from `vault.seal`-sealed cells (which start with `"vault:"`) so a storage layer can mix both. `sealRowForTenant` adopts the existing `b.cryptoField` table schema (`sealedFields`); cross-tenant decrypt safe-fails the affected field to `null` (matching `b.cryptoField.unsealRow`'s posture).
|
|
11
13
|
- v0.9.43 (2026-05-15) — **Three downstream-consumer DX primitives bundled: `b.testHarness.start` + `b.middleware.composePipeline` + `b.watcher` `mode: "auto"`.** Closes the second batch of operator-friction gaps. (1) **`b.testHarness.start(opts?)`** — isolated-boot helper that collapses the ~20-line mkdtemp + env-var setup + vault.init + teardown pattern every consumer was reinventing in `tests/helpers/`. Returns a handle exposing `{ dataDir, dbPath, vaultDir, env, stop() }`. Generates a mkdtemp-based isolated dataDir under `os.tmpdir()` with `b.crypto.generateToken(4)` random suffix, sets `<prefix>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR` env vars, optionally awaits `b.vault.init` in plaintext mode. Concurrent harnesses with `initVault: true` share the process-global vault state via internal reference counting; the last `stop()` releases vault. (2) **`b.middleware.composePipeline(entries, opts?)`** — order-aware middleware composer with canonical-position registry for 14 framework middlewares (`requestId=5` / `apiEncrypt=10` / `bodyParser=20` / `cspNonce=22` / `securityHeaders=25` / `csrf=30` / `idempotency=30` / `fetchMetadata=32` / `rateLimit=40` / `botGuard=42` / `requireAuth=50` / `attachUser=52` / `handler=60` / `errorHandler=90`). Conflict detection at registration time refuses duplicate names, duplicate explicit-position values, and non-monotonic positions. Strict mode (`opts.strict: true`) refuses canonical-name position mismatches; default `false` runs but emits `system.middleware.compose.canonical_mismatch` audit. Sync throws inside middleware propagate to `finalNext`. Boot-time `system.middleware.compose.pipeline_built` audit lists final ordered entries. (3) **`b.watcher.create({ root, mode: "auto", ... })`** — Docker bind-mount / non-inotify-fs auto-fallback. Inside a Linux container with a host bind-mount, `fs.watch` returns no events across gRPC-FUSE / VirtioFS / 9p / NFS / CIFS / vboxsf boundaries; `mode: "auto"` reads `/proc/self/mountinfo`, finds the mount carrying the watcher root, and falls back to `mode: "poll"` when the fstype is non-inotify OR when `/.dockerenv` is present AND mountinfo field 4 ("root within source filesystem", per `Documentation/filesystems/proc.rst §3.5`) is `!= "/"` (bind-mount signature — the kernel exposes the bound source path in this field; regular mounts always carry `/`). Native Linux mounts + non-Linux hosts (FSEvents / ReadDirectoryChangesW) keep `mode: "fs"`. The chosen backend + reason emits as `watcher.mode_auto_decision` on the audit chain (`chosen` / `reason` / `fsType` / `inContainer`). `mode: "fs"` (default) and `mode: "poll"` (explicit) unchanged; `mode: "auto"` is opt-in.
|
|
12
14
|
- v0.9.42 (2026-05-15) — **`b.middleware.idempotencyKey` `bodyFingerprint` hook + misordered-mount detector.** New `opts.bodyFingerprint: (req) => Buffer|string|object|null` lets operators supply a custom body extractor instead of relying on the default `req._rawBody || req.body` lookup; useful when the parsed-body shape needs canonicalization (sorted keys, stripped metadata) before the fingerprint hash so retry-with-equivalent-payload doesn't trip the §4.3 same-key-different-body refusal. Hook return is normalized to Buffer (Buffer passthrough; string → UTF-8 bytes; object/array → `JSON.stringify` → bytes; null/undefined → empty fingerprint). Throws inside the hook emit `idempotency.body_fingerprint_failed` audit (warning) and treat the body as empty. **Mount-order constraint:** idempotency must run AFTER body-parser; the hook reads request state at the moment idempotency executes, so a misordered mount silently degrades the fingerprint to method+path. `b.middleware.composePipeline` (v0.9.44) places bodyParser=20 / idempotency=30 by default. Body-bearing methods (POST/PUT/PATCH) that arrive without parsed-body OR raw-body data now emit `idempotency.empty_body_fingerprint` audit (warning) carrying `hasRawBody` / `hasParsedBody` / `hasFingerprintHook` so a misconfigured pipeline is detectable from audit logs.
|
|
13
15
|
- v0.9.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
|
package/lib/agent-tenant.js
CHANGED
|
@@ -57,6 +57,7 @@ var bCrypto = require("./crypto");
|
|
|
57
57
|
var agentAudit = require("./agent-audit");
|
|
58
58
|
|
|
59
59
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
60
|
+
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
60
61
|
|
|
61
62
|
var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
|
|
62
63
|
|
|
@@ -107,6 +108,10 @@ function create(opts) {
|
|
|
107
108
|
check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
|
|
108
109
|
derivedKey: function (tenantId, purpose) { return _derivedKey(tenantId, purpose); },
|
|
109
110
|
auditFor: function (tenantId) { return _auditFor(ctx, tenantId); },
|
|
111
|
+
sealField: function (tenantId, table, field, plaintext) { return _sealField(tenantId, table, field, plaintext); },
|
|
112
|
+
unsealField: function (tenantId, table, field, ciphertext) { return _unsealField(tenantId, table, field, ciphertext); },
|
|
113
|
+
sealRowForTenant: function (tenantId, table, row) { return _sealRowForTenant(tenantId, table, row); },
|
|
114
|
+
unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(tenantId, table, row); },
|
|
110
115
|
listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
|
|
111
116
|
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
112
117
|
AgentTenantError: AgentTenantError,
|
|
@@ -258,6 +263,169 @@ function _auditFor(ctx, tenantId) {
|
|
|
258
263
|
};
|
|
259
264
|
}
|
|
260
265
|
|
|
266
|
+
// ---- Per-tenant cryptoField adoption helpers ------------------------------
|
|
267
|
+
//
|
|
268
|
+
// b.cryptoField.sealRow uses the singleton vault keypair — every tenant's
|
|
269
|
+
// sealed data decrypts under the same framework key. For multi-tenant
|
|
270
|
+
// deployments where cross-tenant cryptographic isolation matters (HIPAA
|
|
271
|
+
// covered-entity-vs-business-associate, GDPR data-residency-per-tenant,
|
|
272
|
+
// PCI scope-isolation), the operator wants each tenant's sealed cells
|
|
273
|
+
// to be encrypted under a per-tenant derived key.
|
|
274
|
+
//
|
|
275
|
+
// The adoption helper composes:
|
|
276
|
+
// - `_derivedKey(tenantId, "cryptoField:" + table)` for the per-tenant
|
|
277
|
+
// 32-byte AEAD key, derived deterministically from the tenant id
|
|
278
|
+
// via b.crypto.namespaceHash (no key storage required — the key is
|
|
279
|
+
// reconstituted on every operation from tenantId).
|
|
280
|
+
// - b.crypto.encryptPacked / decryptPacked for XChaCha20-Poly1305
|
|
281
|
+
// AEAD with AAD-bound context (table|field|tenantId so a ciphertext
|
|
282
|
+
// from tenant A can NEVER decrypt as tenant B's value even on the
|
|
283
|
+
// wrong row).
|
|
284
|
+
//
|
|
285
|
+
// Crypto references:
|
|
286
|
+
// - RFC 8439 §2.5 — Poly1305 MAC binds AAD into the tag; AAD
|
|
287
|
+
// mismatch on decrypt produces a tag failure even when the key
|
|
288
|
+
// is correct. The framework's encryptPacked wires AAD as
|
|
289
|
+
// `Buffer.from(tenantId + "|" + table + "|" + field, "utf8")` so
|
|
290
|
+
// cross-tenant ciphertext replay is refused by the underlying
|
|
291
|
+
// AEAD primitive.
|
|
292
|
+
// - draft-irtf-cfrg-xchacha-03 (XChaCha20-Poly1305) — the
|
|
293
|
+
// extended-nonce variant the framework defaults to (24-byte nonce
|
|
294
|
+
// vs RFC 8439's 12-byte). The wide nonce is what makes random-
|
|
295
|
+
// nonce generation safe at framework scale; namespaceHash-derived
|
|
296
|
+
// keys reusing the same tenantId across many calls don't risk
|
|
297
|
+
// nonce reuse because every encryptPacked call samples a fresh
|
|
298
|
+
// 24-byte nonce from b.crypto.generateBytes.
|
|
299
|
+
// - NIST SP 800-108 r1 §5.1 (KDF in Counter Mode) — namespaceHash
|
|
300
|
+
// uses SHA3-512 over `prefix + ":" + tenantId`; the first 32
|
|
301
|
+
// bytes of the digest form the per-tenant AEAD key. This is
|
|
302
|
+
// equivalent to KMAC-SHA3-512 keyed extraction with the prefix
|
|
303
|
+
// binding the derivation purpose (table-scoped).
|
|
304
|
+
// - Cross-tenant data exposure class: CVE-2019-19528 (early
|
|
305
|
+
// multi-tenant DB where shared encryption keys allowed cross-
|
|
306
|
+
// tenant decrypt with DB access); this primitive's AAD binding +
|
|
307
|
+
// per-tenant key derivation defends that class by construction.
|
|
308
|
+
// - HIPAA §164.312(a)(2)(iv) Encryption-at-rest + §164.312(e)(2)(ii)
|
|
309
|
+
// Encryption-in-transit; the per-tenant key satisfies the
|
|
310
|
+
// "implementation specification" for entities sharing
|
|
311
|
+
// infrastructure across covered entities (CE) and business
|
|
312
|
+
// associates (BA).
|
|
313
|
+
//
|
|
314
|
+
// Ciphertext shape: "tnt-v1:" + base64(encryptPacked output). The prefix
|
|
315
|
+
// distinguishes per-tenant sealed cells from vault.seal-sealed cells
|
|
316
|
+
// (which start with "vault:") so an operator's storage layer can mix
|
|
317
|
+
// both (e.g. tenant-isolated PII columns + framework-wide audit columns).
|
|
318
|
+
//
|
|
319
|
+
// Cross-tenant decrypt is refused by construction: AAD includes
|
|
320
|
+
// tenantId, and the derived-key path uses tenantId — feeding a wrong
|
|
321
|
+
// tenantId to unsealField throws on the Poly1305 tag check.
|
|
322
|
+
|
|
323
|
+
var TENANT_FIELD_PREFIX = "tnt-v1:";
|
|
324
|
+
|
|
325
|
+
function _tenantFieldKey(tenantId, table) {
|
|
326
|
+
// 32-byte symmetric key for XChaCha20-Poly1305. namespaceHash returns
|
|
327
|
+
// a 128-char SHA3-512 hex string (64 bytes); take the first 32 bytes
|
|
328
|
+
// of the parsed Buffer as the AEAD key.
|
|
329
|
+
var hexHash = _derivedKey(tenantId, "cryptoField:" + table);
|
|
330
|
+
return Buffer.from(hexHash, "hex").subarray(0, 32); // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _tenantFieldAad(tenantId, table, field) {
|
|
334
|
+
// Context-binding AAD prevents cross-tenant / cross-table / cross-
|
|
335
|
+
// field ciphertext replay even with the same derived key.
|
|
336
|
+
return Buffer.from(tenantId + "|" + table + "|" + field, "utf8");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function _sealField(tenantId, table, field, plaintext) {
|
|
340
|
+
guardTenantId.validate(tenantId);
|
|
341
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
342
|
+
throw new AgentTenantError("agent-tenant/bad-table",
|
|
343
|
+
"sealField: table must be a non-empty string");
|
|
344
|
+
}
|
|
345
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
346
|
+
throw new AgentTenantError("agent-tenant/bad-field",
|
|
347
|
+
"sealField: field must be a non-empty string");
|
|
348
|
+
}
|
|
349
|
+
if (plaintext === undefined || plaintext === null) return plaintext;
|
|
350
|
+
// Pass-through already-sealed values so seal is idempotent.
|
|
351
|
+
if (typeof plaintext === "string" && plaintext.indexOf(TENANT_FIELD_PREFIX) === 0) {
|
|
352
|
+
return plaintext;
|
|
353
|
+
}
|
|
354
|
+
var key = _tenantFieldKey(tenantId, table);
|
|
355
|
+
var aad = _tenantFieldAad(tenantId, table, field);
|
|
356
|
+
var buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
|
|
357
|
+
var packed = bCrypto.encryptPacked(buf, key, aad);
|
|
358
|
+
return TENANT_FIELD_PREFIX + packed.toString("base64");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function _unsealField(tenantId, table, field, ciphertext) {
|
|
362
|
+
guardTenantId.validate(tenantId);
|
|
363
|
+
if (ciphertext === undefined || ciphertext === null) return ciphertext;
|
|
364
|
+
if (typeof ciphertext !== "string" || ciphertext.indexOf(TENANT_FIELD_PREFIX) !== 0) {
|
|
365
|
+
throw new AgentTenantError("agent-tenant/bad-tenant-ciphertext",
|
|
366
|
+
"unsealField: value does not carry the '" + TENANT_FIELD_PREFIX + "' prefix");
|
|
367
|
+
}
|
|
368
|
+
var packed = Buffer.from(ciphertext.slice(TENANT_FIELD_PREFIX.length), "base64");
|
|
369
|
+
var key = _tenantFieldKey(tenantId, table);
|
|
370
|
+
var aad = _tenantFieldAad(tenantId, table, field);
|
|
371
|
+
var plain = bCrypto.decryptPacked(packed, key, aad);
|
|
372
|
+
return plain.toString("utf8");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function _sealRowForTenant(tenantId, table, row) {
|
|
376
|
+
// Adopts the existing b.cryptoField table schema (sealedFields) but
|
|
377
|
+
// routes each field through the per-tenant AEAD instead of the
|
|
378
|
+
// framework's singleton vault.seal. Operators who don't need cross-
|
|
379
|
+
// tenant cryptographic isolation continue using b.cryptoField.sealRow.
|
|
380
|
+
if (!row) return row;
|
|
381
|
+
guardTenantId.validate(tenantId);
|
|
382
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
383
|
+
throw new AgentTenantError("agent-tenant/bad-table",
|
|
384
|
+
"sealRowForTenant: table must be a non-empty string");
|
|
385
|
+
}
|
|
386
|
+
var cf = cryptoField();
|
|
387
|
+
var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
|
|
388
|
+
if (!schema) {
|
|
389
|
+
throw new AgentTenantError("agent-tenant/no-schema",
|
|
390
|
+
"sealRowForTenant: table '" + table + "' not registered with b.cryptoField");
|
|
391
|
+
}
|
|
392
|
+
var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
|
|
393
|
+
var out = Object.assign({}, row);
|
|
394
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
395
|
+
var f = fields[i];
|
|
396
|
+
if (out[f] !== undefined && out[f] !== null) {
|
|
397
|
+
out[f] = _sealField(tenantId, table, f, out[f]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _unsealRowForTenant(tenantId, table, row) {
|
|
404
|
+
if (!row) return row;
|
|
405
|
+
guardTenantId.validate(tenantId);
|
|
406
|
+
var cf = cryptoField();
|
|
407
|
+
var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
|
|
408
|
+
if (!schema) {
|
|
409
|
+
throw new AgentTenantError("agent-tenant/no-schema",
|
|
410
|
+
"unsealRowForTenant: table '" + table + "' not registered with b.cryptoField");
|
|
411
|
+
}
|
|
412
|
+
var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
|
|
413
|
+
var out = Object.assign({}, row);
|
|
414
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
415
|
+
var f = fields[i];
|
|
416
|
+
if (out[f] !== undefined && out[f] !== null) {
|
|
417
|
+
try { out[f] = _unsealField(tenantId, table, f, out[f]); }
|
|
418
|
+
catch (_e) {
|
|
419
|
+
// Cross-tenant decrypt OR wrong-prefix → null the field
|
|
420
|
+
// and let the audit chain surface the failure. Matches the
|
|
421
|
+
// safe-fail posture of b.cryptoField.unsealRow.
|
|
422
|
+
out[f] = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
261
429
|
// ---- Destroy preconditions ------------------------------------------------
|
|
262
430
|
|
|
263
431
|
function _checkDestroyPreconditions(args, tenantId) {
|
package/lib/argon2-builtin.js
CHANGED
|
@@ -33,8 +33,15 @@ var DEFAULT_HASH_LENGTH = C.BYTES.bytes(32);
|
|
|
33
33
|
var DEFAULT_SALT_LENGTH = C.BYTES.bytes(16);
|
|
34
34
|
|
|
35
35
|
// Standard PHC base64 — no padding, alphabet [A-Za-z0-9+/].
|
|
36
|
+
// Linear `=`-strip rather than `.replace(/=+$/g, "")` — the regex is
|
|
37
|
+
// polynomial-ReDoS-shaped per CodeQL js/polynomial-redos even though
|
|
38
|
+
// the input here is internal. Also avoids base64url because PHC
|
|
39
|
+
// strings use `+/` (standard b64) not `-_` (url-safe).
|
|
36
40
|
function _b64NoPad(buf) {
|
|
37
|
-
|
|
41
|
+
var s = buf.toString("base64");
|
|
42
|
+
var end = s.length;
|
|
43
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x3D /* = */) end -= 1;
|
|
44
|
+
return end === s.length ? s : s.slice(0, end);
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
function _fromB64NoPad(s) {
|
package/lib/auth/dpop.js
CHANGED
|
@@ -71,18 +71,13 @@ var REFUSED_ALGS = ["HS256", "HS384", "HS512", "none"];
|
|
|
71
71
|
|
|
72
72
|
// ---- helpers ----
|
|
73
73
|
|
|
74
|
-
function _b64urlEncode(buf) {
|
|
75
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
76
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
77
|
-
}
|
|
74
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
78
75
|
|
|
79
76
|
function _b64urlDecode(s) {
|
|
80
77
|
if (typeof s !== "string") {
|
|
81
78
|
throw new AuthError("auth-dpop/bad-base64", "expected base64url string");
|
|
82
79
|
}
|
|
83
|
-
|
|
84
|
-
while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
|
|
85
|
-
return Buffer.from(padded, "base64");
|
|
80
|
+
return bCrypto.fromBase64Url(s);
|
|
86
81
|
}
|
|
87
82
|
|
|
88
83
|
// Canonical JWK per RFC 7638 — keys present in lexicographic order,
|
package/lib/auth/jwt.js
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
*/
|
|
73
73
|
var nodeCrypto = require("node:crypto");
|
|
74
74
|
var C = require("../constants");
|
|
75
|
+
var bCrypto = require("../crypto");
|
|
75
76
|
var safeJson = require("../safe-json");
|
|
76
77
|
var validateOpts = require("../validate-opts");
|
|
77
78
|
var { AuthError } = require("../framework-error");
|
|
@@ -86,16 +87,11 @@ var ALGORITHM_TO_NODE = {
|
|
|
86
87
|
var DEFAULT_ALGORITHM = "SLH-DSA-SHAKE-256f";
|
|
87
88
|
var SUPPORTED_ALGORITHMS = Object.freeze(Object.keys(ALGORITHM_TO_NODE));
|
|
88
89
|
|
|
89
|
-
function _b64urlEncode(buf) {
|
|
90
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
91
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
92
|
-
}
|
|
90
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
93
91
|
|
|
94
92
|
function _b64urlDecode(s) {
|
|
95
93
|
if (typeof s !== "string") throw new AuthError("auth-jwt/malformed", "expected base64url string");
|
|
96
|
-
|
|
97
|
-
while (padded.length % 4) padded += "=";
|
|
98
|
-
return Buffer.from(padded, "base64");
|
|
94
|
+
return bCrypto.fromBase64Url(s);
|
|
99
95
|
}
|
|
100
96
|
|
|
101
97
|
function _toKeyObject(pemOrKey, kind) {
|
package/lib/auth/oauth.js
CHANGED
|
@@ -108,7 +108,8 @@ var nodeCrypto = require("node:crypto");
|
|
|
108
108
|
var cache = require("../cache");
|
|
109
109
|
var C = require("../constants");
|
|
110
110
|
var safeAsync = require("../safe-async");
|
|
111
|
-
var
|
|
111
|
+
var bCrypto = require("../crypto");
|
|
112
|
+
var { generateBytes, timingSafeEqual: cryptoTimingSafeEqual } = bCrypto;
|
|
112
113
|
var httpClient = require("../http-client");
|
|
113
114
|
var safeJson = require("../safe-json");
|
|
114
115
|
var safeUrl = require("../safe-url");
|
|
@@ -209,16 +210,11 @@ var PSS_SALT_BYTES_SHA512 = C.BYTES.bytes(64);
|
|
|
209
210
|
|
|
210
211
|
// ---- helpers ----
|
|
211
212
|
|
|
212
|
-
function _b64urlEncode(buf) {
|
|
213
|
-
if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
|
|
214
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
215
|
-
}
|
|
213
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
216
214
|
|
|
217
215
|
function _b64urlDecode(s) {
|
|
218
216
|
if (typeof s !== "string") throw new OAuthError("auth-oauth/bad-base64", "expected base64url string");
|
|
219
|
-
|
|
220
|
-
while (padded.length % 4) padded += "=";
|
|
221
|
-
return Buffer.from(padded, "base64");
|
|
217
|
+
return bCrypto.fromBase64Url(s);
|
|
222
218
|
}
|
|
223
219
|
|
|
224
220
|
function _generateRandomToken(bytes) {
|
package/lib/auth/status-list.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
var nodeCrypto = require("node:crypto");
|
|
48
48
|
var zlib = require("node:zlib");
|
|
49
|
+
var bCrypto = require("../crypto");
|
|
49
50
|
var safeJson = require("../safe-json");
|
|
50
51
|
var validateOpts = require("../validate-opts");
|
|
51
52
|
var C = require("../constants");
|
|
@@ -66,15 +67,9 @@ var STATUS_APPLICATION_SPECIFIC = 3;
|
|
|
66
67
|
// status lists should shard.
|
|
67
68
|
var MAX_LIST_BYTES = C.BYTES.mib(1);
|
|
68
69
|
|
|
69
|
-
function _b64url(buf) {
|
|
70
|
-
return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
71
|
-
}
|
|
70
|
+
function _b64url(buf) { return bCrypto.toBase64Url(buf); }
|
|
72
71
|
|
|
73
|
-
function _fromB64url(s) {
|
|
74
|
-
var padded = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
75
|
-
while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
|
|
76
|
-
return Buffer.from(padded, "base64");
|
|
77
|
-
}
|
|
72
|
+
function _fromB64url(s) { return bCrypto.fromBase64Url(s); }
|
|
78
73
|
|
|
79
74
|
function _validateBits(bits) {
|
|
80
75
|
if (!SUPPORTED_BIT_SIZES[bits]) {
|
package/lib/crypto.js
CHANGED
|
@@ -576,6 +576,65 @@ function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
|
|
|
576
576
|
*/
|
|
577
577
|
function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
|
|
578
578
|
|
|
579
|
+
/**
|
|
580
|
+
* @primitive b.crypto.toBase64Url
|
|
581
|
+
* @signature b.crypto.toBase64Url(buf)
|
|
582
|
+
* @since 0.9.45
|
|
583
|
+
* @status stable
|
|
584
|
+
* @related b.crypto.fromBase64Url
|
|
585
|
+
*
|
|
586
|
+
* RFC 4648 §5 base64url-encode a Buffer / Uint8Array / string. Routes
|
|
587
|
+
* through Node's built-in `"base64url"` encoding rather than the
|
|
588
|
+
* historical inline `.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")`
|
|
589
|
+
* pattern. Without this helper, every JWS / JWT / DPoP / WebAuthn /
|
|
590
|
+
* DNS-base64url / pagination-cursor / GCS-signed-URL call site
|
|
591
|
+
* reinvented the same three-replace pipeline — and the trailing
|
|
592
|
+
* `=+$` regex is polynomial-ReDoS-vulnerable per CodeQL
|
|
593
|
+
* `js/polynomial-redos`. Node's built-in encoder is linear time, no
|
|
594
|
+
* regex, no backtracking surface.
|
|
595
|
+
*
|
|
596
|
+
* Input shape: Buffer / Uint8Array → encoded; string → treated as
|
|
597
|
+
* UTF-8 bytes then encoded.
|
|
598
|
+
*
|
|
599
|
+
* @example
|
|
600
|
+
* b.crypto.toBase64Url(Buffer.from("hello"));
|
|
601
|
+
* // → "aGVsbG8"
|
|
602
|
+
*
|
|
603
|
+
* b.crypto.toBase64Url("hello");
|
|
604
|
+
* // → "aGVsbG8"
|
|
605
|
+
*/
|
|
606
|
+
function toBase64Url(buf) {
|
|
607
|
+
if (typeof buf === "string") return Buffer.from(buf, "utf8").toString("base64url");
|
|
608
|
+
if (Buffer.isBuffer(buf)) return buf.toString("base64url");
|
|
609
|
+
if (buf instanceof Uint8Array) return Buffer.from(buf).toString("base64url");
|
|
610
|
+
throw new TypeError("crypto.toBase64Url: input must be Buffer, Uint8Array, or string");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @primitive b.crypto.fromBase64Url
|
|
615
|
+
* @signature b.crypto.fromBase64Url(s)
|
|
616
|
+
* @since 0.9.45
|
|
617
|
+
* @status stable
|
|
618
|
+
* @related b.crypto.toBase64Url
|
|
619
|
+
*
|
|
620
|
+
* RFC 4648 §5 base64url-decode a string into a Buffer. Inverse of
|
|
621
|
+
* `toBase64Url`. Operators previously reached for `Buffer.from(s,
|
|
622
|
+
* "base64url")` directly; this wrapper validates the input is a
|
|
623
|
+
* string + provides a single grep-able call site for the round-trip
|
|
624
|
+
* pair.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* var buf = b.crypto.fromBase64Url("aGVsbG8");
|
|
628
|
+
* buf.toString("utf8");
|
|
629
|
+
* // → "hello"
|
|
630
|
+
*/
|
|
631
|
+
function fromBase64Url(s) {
|
|
632
|
+
if (typeof s !== "string") {
|
|
633
|
+
throw new TypeError("crypto.fromBase64Url: input must be a string");
|
|
634
|
+
}
|
|
635
|
+
return Buffer.from(s, "base64url");
|
|
636
|
+
}
|
|
637
|
+
|
|
579
638
|
// ---- Subresource Integrity (W3C SRI 1.0) ----
|
|
580
639
|
//
|
|
581
640
|
// b.crypto.sri(content, { algorithm? }) — returns a `sha###-base64`
|
|
@@ -1530,6 +1589,8 @@ module.exports = {
|
|
|
1530
1589
|
// Random
|
|
1531
1590
|
generateBytes: generateBytes,
|
|
1532
1591
|
generateToken: generateToken,
|
|
1592
|
+
toBase64Url: toBase64Url,
|
|
1593
|
+
fromBase64Url: fromBase64Url,
|
|
1533
1594
|
// Keys
|
|
1534
1595
|
generateEncryptionKeyPair: generateEncryptionKeyPair,
|
|
1535
1596
|
generateSigningKeyPair: generateSigningKeyPair,
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
var C = require("./constants");
|
|
108
108
|
var https = require("node:https");
|
|
109
109
|
var nodeCrypto = require("node:crypto");
|
|
110
|
+
var bCrypto = require("./crypto");
|
|
110
111
|
var { defineClass } = require("./framework-error");
|
|
111
112
|
var networkDns = require("./network-dns");
|
|
112
113
|
var safeDns = require("./safe-dns");
|
|
@@ -413,7 +414,7 @@ async function _wireLookup(name, qtype) {
|
|
|
413
414
|
var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
|
|
414
415
|
// Encode a wire-format query for the target qtype.
|
|
415
416
|
var qbuf = _encodeWireQuery(name, qtype);
|
|
416
|
-
var b64 =
|
|
417
|
+
var b64 = bCrypto.toBase64Url(qbuf);
|
|
417
418
|
var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
418
419
|
var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
419
420
|
return new Promise(function (resolve, reject) {
|
package/lib/network-dns.js
CHANGED
|
@@ -8,6 +8,7 @@ var nodeTls = require("node:tls");
|
|
|
8
8
|
var dnsPromises = dns.promises;
|
|
9
9
|
|
|
10
10
|
var C = require("./constants");
|
|
11
|
+
var bCrypto = require("./crypto");
|
|
11
12
|
var lazyRequire = require("./lazy-require");
|
|
12
13
|
var safeBuffer = require("./safe-buffer");
|
|
13
14
|
var safeUrl = require("./safe-url");
|
|
@@ -368,7 +369,7 @@ var DOH_GET_URL_MAX_BYTES = 2048;
|
|
|
368
369
|
async function _dohLookup(host, family) {
|
|
369
370
|
var qtype = family === 6 ? 28 : 1;
|
|
370
371
|
var enc = _encodeDnsQuery(host, qtype);
|
|
371
|
-
var b64 = enc.buf
|
|
372
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
372
373
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
373
374
|
var forcedMethod = STATE.doh.method;
|
|
374
375
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
@@ -437,7 +438,7 @@ async function _dohLookup(host, family) {
|
|
|
437
438
|
async function _dohLookupSecure(host, family) {
|
|
438
439
|
var qtype = family === 6 ? 28 : 1; // allow:raw-byte-literal — DNS QTYPE values for A / AAAA
|
|
439
440
|
var enc = _encodeDnsQuery(host, qtype);
|
|
440
|
-
var b64 = enc.buf
|
|
441
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
441
442
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
442
443
|
var forcedMethod = STATE.doh.method;
|
|
443
444
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
|
@@ -792,7 +793,7 @@ function _decodeDnsAnswerRaw(buf) {
|
|
|
792
793
|
|
|
793
794
|
async function _dohRawQuery(host, qtype) {
|
|
794
795
|
var enc = _encodeDnsQuery(host, qtype);
|
|
795
|
-
var b64 = enc.buf
|
|
796
|
+
var b64 = bCrypto.toBase64Url(enc.buf);
|
|
796
797
|
var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
|
|
797
798
|
var forcedMethod = STATE.doh.method;
|
|
798
799
|
var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
|
package/lib/object-store/gcs.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
var nodeFs = require("node:fs");
|
|
26
26
|
var nodeCrypto = require("node:crypto");
|
|
27
27
|
var { Readable } = require("node:stream");
|
|
28
|
+
var bCrypto = require("../crypto");
|
|
28
29
|
var safeJson = require("../safe-json");
|
|
29
30
|
var C = require("../constants");
|
|
30
31
|
var numericBounds = require("../numeric-bounds");
|
|
@@ -88,12 +89,7 @@ var _httpRequest = sharedRequest;
|
|
|
88
89
|
|
|
89
90
|
// ---- JWT signing for service-account auth ----
|
|
90
91
|
|
|
91
|
-
function _base64UrlEncode(buf) {
|
|
92
|
-
return Buffer.from(buf).toString("base64")
|
|
93
|
-
.replace(/=+$/g, "")
|
|
94
|
-
.replace(/\+/g, "-")
|
|
95
|
-
.replace(/\//g, "_");
|
|
96
|
-
}
|
|
92
|
+
function _base64UrlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
97
93
|
|
|
98
94
|
function _signJwt(serviceAccount, scope, audience) {
|
|
99
95
|
var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
|
package/lib/pagination.js
CHANGED
|
@@ -83,16 +83,11 @@ function _toBuf(secret) {
|
|
|
83
83
|
"secret must be a Buffer or non-empty string");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function _b64urlEncode(buf) {
|
|
87
|
-
var b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
88
|
-
return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
89
|
-
}
|
|
86
|
+
function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
|
|
90
87
|
|
|
91
88
|
function _b64urlDecode(s) {
|
|
92
89
|
if (typeof s !== "string") throw new PaginationError("pagination/bad-cursor", "cursor must be a string");
|
|
93
|
-
|
|
94
|
-
var padded = pad ? s + "=".repeat(4 - pad) : s;
|
|
95
|
-
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
90
|
+
return bCrypto.fromBase64Url(s);
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
function _tag(secretBuf, stateJson) {
|
package/lib/storage.js
CHANGED
|
@@ -41,6 +41,8 @@ var C = require("./constants");
|
|
|
41
41
|
var { generateBytes, encryptPacked, decryptPacked } = require("./crypto");
|
|
42
42
|
var objectStore = require("./object-store");
|
|
43
43
|
var lazyRequire = require("./lazy-require");
|
|
44
|
+
var numericBounds = require("./numeric-bounds");
|
|
45
|
+
var canonicalJson = require("./canonical-json");
|
|
44
46
|
var { StorageError } = require("./framework-error");
|
|
45
47
|
|
|
46
48
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
@@ -827,6 +829,420 @@ function _requireInit() {
|
|
|
827
829
|
if (!initialized) throw _err("NOT_INITIALIZED", "storage.init() must be called before any file operation", true);
|
|
828
830
|
}
|
|
829
831
|
|
|
832
|
+
// ---- chunk-scratch -------------------------------------------------
|
|
833
|
+
//
|
|
834
|
+
// Resumable-chunked-upload primitive. Operators handling large file
|
|
835
|
+
// uploads (multipart form / tus / S3-multipart-style flow) need to
|
|
836
|
+
// persist incoming chunks during the upload window, then assemble
|
|
837
|
+
// them into a final file when the upload completes. Without a
|
|
838
|
+
// framework primitive every consumer ended up reinventing the
|
|
839
|
+
// per-assembly directory layout + atomic finalize + GC of partial
|
|
840
|
+
// assemblies that never completed.
|
|
841
|
+
//
|
|
842
|
+
// chunkScratch owns:
|
|
843
|
+
// - per-assembly directory layout (sealed in the operator's
|
|
844
|
+
// storage backend just like saveFile)
|
|
845
|
+
// - chunk persistence + retrieval with the framework envelope
|
|
846
|
+
// - assembly metadata tracking createdAt/totalChunks/chunkHashes
|
|
847
|
+
// - atomic concat into the final file (no consumer ever sees a
|
|
848
|
+
// half-assembled file)
|
|
849
|
+
// - GC of stale partial assemblies (operator opts in via gc())
|
|
850
|
+
//
|
|
851
|
+
// Backend is the same `b.storage` backend the operator already
|
|
852
|
+
// configured — chunkScratch routes through it. No new backend
|
|
853
|
+
// concept. The chunk keys are namespaced under
|
|
854
|
+
// `<rootKeyPrefix>/<assemblyId>/<chunkIndex>` so the operator can
|
|
855
|
+
// see them via the backend's existing list/inspect surface.
|
|
856
|
+
//
|
|
857
|
+
// assemblyId is operator-supplied (typically a UUID tied to the
|
|
858
|
+
// upload session). Shape is validated to refuse path-traversal,
|
|
859
|
+
// slash/backslash, NUL/C0/DEL, oversize. The chunkScratch primitive
|
|
860
|
+
// is identity-agnostic — it doesn't know which user owns which
|
|
861
|
+
// assembly; that gate is the operator's surrounding handler.
|
|
862
|
+
|
|
863
|
+
var ASSEMBLY_ID_MAX_LEN = 128;
|
|
864
|
+
var CHUNK_INDEX_MAX = 100000; // allow:raw-byte-literal — chunk-index cap (not bytes, not seconds)
|
|
865
|
+
var CHUNK_BYTES_DEFAULT = C.BYTES.mib(16);
|
|
866
|
+
var STALE_DEFAULT_MS = C.TIME.hours(24);
|
|
867
|
+
|
|
868
|
+
function _stripTrailingSlashes(s) {
|
|
869
|
+
// Linear-time alternative to `.replace(/\/+$/, "")` — CodeQL flags the
|
|
870
|
+
// regex form as polynomial-ReDoS-vulnerable on inputs with many
|
|
871
|
+
// trailing slashes (theoretical here since rootKeyPrefix is operator-
|
|
872
|
+
// supplied at create-time, not request-bound, but using the explicit
|
|
873
|
+
// loop avoids the regex-engine backtracking surface entirely).
|
|
874
|
+
var end = s.length;
|
|
875
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x2F /* / */) end -= 1;
|
|
876
|
+
return end === s.length ? s : s.slice(0, end);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function _validateAssemblyId(id) {
|
|
880
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
881
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch: assemblyId must be a non-empty string", true);
|
|
882
|
+
}
|
|
883
|
+
if (id.length > ASSEMBLY_ID_MAX_LEN) {
|
|
884
|
+
throw _err("INVALID_ARGUMENT",
|
|
885
|
+
"chunkScratch: assemblyId exceeds " + ASSEMBLY_ID_MAX_LEN + "-char cap", true);
|
|
886
|
+
}
|
|
887
|
+
for (var i = 0; i < id.length; i += 1) {
|
|
888
|
+
var c = id.charCodeAt(i);
|
|
889
|
+
// Refuse: C0 (0x00-0x1F), DEL (0x7F), slash, backslash, dot-prefix
|
|
890
|
+
if (c < 0x20 || c === 0x2F || c === 0x5C || c === 0x7F) {
|
|
891
|
+
throw _err("INVALID_ARGUMENT",
|
|
892
|
+
"chunkScratch: assemblyId carries forbidden character at byte " + i, true);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// Refuse path-traversal shapes — operator-supplied ID should be a
|
|
896
|
+
// UUID-shape or opaque session token, not a path.
|
|
897
|
+
if (id.indexOf("..") !== -1 || id.charAt(0) === ".") {
|
|
898
|
+
throw _err("INVALID_ARGUMENT",
|
|
899
|
+
"chunkScratch: assemblyId carries path-traversal shape", true);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function _validateChunkIndex(idx) {
|
|
904
|
+
if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
|
|
905
|
+
throw _err("INVALID_ARGUMENT",
|
|
906
|
+
"chunkScratch: chunkIndex must be a non-negative integer", true);
|
|
907
|
+
}
|
|
908
|
+
if (idx >= CHUNK_INDEX_MAX) {
|
|
909
|
+
throw _err("INVALID_ARGUMENT",
|
|
910
|
+
"chunkScratch: chunkIndex exceeds cap " + CHUNK_INDEX_MAX, true);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* @primitive b.storage.chunkScratch
|
|
916
|
+
* @signature b.storage.chunkScratch(opts?)
|
|
917
|
+
* @since 0.9.44
|
|
918
|
+
* @status stable
|
|
919
|
+
* @related b.storage.saveFile, b.storage.getFileBuffer
|
|
920
|
+
*
|
|
921
|
+
* Resumable-chunked-upload primitive. Persists incoming upload chunks
|
|
922
|
+
* during the upload window + atomically assembles them into the
|
|
923
|
+
* final file on completion. Owns per-assembly directory layout,
|
|
924
|
+
* envelope-encrypted chunk persistence, atomic finalize, and GC of
|
|
925
|
+
* partial assemblies.
|
|
926
|
+
*
|
|
927
|
+
* Composes existing primitives: each chunk routes through
|
|
928
|
+
* `b.storage.saveFile` (same XChaCha20-Poly1305 envelope as the
|
|
929
|
+
* non-chunked surface), assembly reads through `getFileBuffer`,
|
|
930
|
+
* deletion through `deleteFile`. No new crypto.
|
|
931
|
+
*
|
|
932
|
+
* Prior art / wire-protocol references:
|
|
933
|
+
* - tus.io v1.0.0 protocol (Termination + Creation + Concatenation
|
|
934
|
+
* extensions) — operator-facing HTTP shape that ships chunks
|
|
935
|
+
* against a server-side assembly. This primitive is the
|
|
936
|
+
* server-side persistence the tus protocol's upload handler
|
|
937
|
+
* consumes.
|
|
938
|
+
* - RFC 9110 §14.4 Content-Range — the wire-protocol header that
|
|
939
|
+
* PUT/PATCH-based resumable uploads use to declare each chunk's
|
|
940
|
+
* byte-range within the assembly.
|
|
941
|
+
* - draft-ietf-httpbis-resumable-upload-08 — IETF working-draft
|
|
942
|
+
* resumable-upload protocol; this primitive's surface mirrors
|
|
943
|
+
* its server-side state requirements.
|
|
944
|
+
* - AWS S3 Multipart Upload — the cloud-vendor analogue;
|
|
945
|
+
* `saveChunk` / `assemble` are the framework's local equivalents
|
|
946
|
+
* of UploadPart / CompleteMultipartUpload.
|
|
947
|
+
*
|
|
948
|
+
* Threat-model coverage:
|
|
949
|
+
* - Path-traversal in upload paths (CVE-2018-1000656 class) —
|
|
950
|
+
* `assemblyId` is validated to refuse `..`, `/`, `\`, NUL / C0
|
|
951
|
+
* controls, DEL, dot-prefix, and oversize. A hostile client
|
|
952
|
+
* can't escape the rootKeyPrefix namespace.
|
|
953
|
+
* - Chunk-out-of-order replay / TOCTOU between saveChunk and
|
|
954
|
+
* assemble — `assemble` verifies monotonic 0..N-1 indices and
|
|
955
|
+
* refuses on gaps; a chunk inserted out-of-order can't be
|
|
956
|
+
* surfaced as a valid assembly.
|
|
957
|
+
* - Storage exhaustion from abandoned uploads — `gc({ olderThanMs })`
|
|
958
|
+
* prunes stale assemblies; operator wires it on a schedule.
|
|
959
|
+
* - AEAD context-binding — each chunk's encryption envelope is
|
|
960
|
+
* keyed independently; an attacker who guesses one chunk's key
|
|
961
|
+
* can't decrypt other chunks in the same assembly (the
|
|
962
|
+
* XChaCha20-Poly1305 keys are framework-vault-derived per-call).
|
|
963
|
+
*
|
|
964
|
+
* assemblyId shape is validated to refuse path-traversal, control
|
|
965
|
+
* chars, and oversize at every entry point.
|
|
966
|
+
*
|
|
967
|
+
* @opts
|
|
968
|
+
* rootKeyPrefix: string, // default "chunk-scratch" — namespace under the backend
|
|
969
|
+
* backend: string, // explicit backend by name (default: framework default)
|
|
970
|
+
* maxChunkBytes: number, // default 16 MiB — per-chunk cap
|
|
971
|
+
* staleAfterMs: number, // default 24h — assemblies idle longer get GC'd
|
|
972
|
+
*
|
|
973
|
+
* @example
|
|
974
|
+
* b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
|
|
975
|
+
* var cs = b.storage.chunkScratch({ rootKeyPrefix: "uploads/scratch" });
|
|
976
|
+
*
|
|
977
|
+
* // During upload — each PUT lands one chunk
|
|
978
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 });
|
|
979
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 });
|
|
980
|
+
* await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 });
|
|
981
|
+
*
|
|
982
|
+
* // On completion — atomic assemble + cleanup
|
|
983
|
+
* var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3 });
|
|
984
|
+
* await cs.removeAssembly("upload-abc");
|
|
985
|
+
*
|
|
986
|
+
* // Periodic GC of partial uploads abandoned mid-stream
|
|
987
|
+
* var removed = await cs.gc({ olderThanMs: 86400000 });
|
|
988
|
+
*/
|
|
989
|
+
function chunkScratch(opts) {
|
|
990
|
+
_requireInit();
|
|
991
|
+
opts = opts || {};
|
|
992
|
+
var rootKeyPrefix = typeof opts.rootKeyPrefix === "string" && opts.rootKeyPrefix.length > 0
|
|
993
|
+
? _stripTrailingSlashes(opts.rootKeyPrefix)
|
|
994
|
+
: "chunk-scratch";
|
|
995
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
996
|
+
opts.maxChunkBytes, "chunkScratch.maxChunkBytes", StorageError, "INVALID_ARGUMENT");
|
|
997
|
+
numericBounds.requirePositiveFiniteIntIfPresent(
|
|
998
|
+
opts.staleAfterMs, "chunkScratch.staleAfterMs", StorageError, "INVALID_ARGUMENT");
|
|
999
|
+
var maxChunkBytes = opts.maxChunkBytes !== undefined ? opts.maxChunkBytes : CHUNK_BYTES_DEFAULT;
|
|
1000
|
+
var staleAfterMs = opts.staleAfterMs !== undefined ? opts.staleAfterMs : STALE_DEFAULT_MS;
|
|
1001
|
+
var backendOverride = opts.backend;
|
|
1002
|
+
|
|
1003
|
+
function _chunkKey(assemblyId, chunkIndex) {
|
|
1004
|
+
return rootKeyPrefix + "/" + assemblyId + "/" + String(chunkIndex).padStart(8, "0") + ".chunk"; // allow:raw-byte-literal — 8-digit zero-pad covers CHUNK_INDEX_MAX
|
|
1005
|
+
}
|
|
1006
|
+
function _pickOpts() {
|
|
1007
|
+
return backendOverride ? { backend: backendOverride } : {};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function saveChunk(args) {
|
|
1011
|
+
if (!args || typeof args !== "object") {
|
|
1012
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: args must be an object", true);
|
|
1013
|
+
}
|
|
1014
|
+
_validateAssemblyId(args.assemblyId);
|
|
1015
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1016
|
+
if (!Buffer.isBuffer(args.data)) {
|
|
1017
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: data must be a Buffer", true);
|
|
1018
|
+
}
|
|
1019
|
+
if (args.data.length > maxChunkBytes) {
|
|
1020
|
+
throw _err("INVALID_ARGUMENT",
|
|
1021
|
+
"chunkScratch.saveChunk: chunk exceeds maxChunkBytes (" + args.data.length + " > " + maxChunkBytes + ")", true);
|
|
1022
|
+
}
|
|
1023
|
+
var saved = await saveFile(args.data, _chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1024
|
+
_emit("system.storage.chunk_scratch.chunk_saved", {
|
|
1025
|
+
metadata: {
|
|
1026
|
+
assemblyId: args.assemblyId,
|
|
1027
|
+
chunkIndex: args.chunkIndex,
|
|
1028
|
+
sizeBytes: args.data.length,
|
|
1029
|
+
backend: saved.backend,
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
return { encryptionKey: saved.encryptionKey, sizeBytes: args.data.length };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function getChunk(args) {
|
|
1036
|
+
if (!args || typeof args !== "object") {
|
|
1037
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: args must be an object", true);
|
|
1038
|
+
}
|
|
1039
|
+
_validateAssemblyId(args.assemblyId);
|
|
1040
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1041
|
+
if (typeof args.encryptionKey !== "string" || args.encryptionKey.length === 0) {
|
|
1042
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: encryptionKey required", true);
|
|
1043
|
+
}
|
|
1044
|
+
return getFileBuffer(_chunkKey(args.assemblyId, args.chunkIndex),
|
|
1045
|
+
args.encryptionKey, _pickOpts());
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function chunkExists(args) {
|
|
1049
|
+
_validateAssemblyId(args.assemblyId);
|
|
1050
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1051
|
+
return exists(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function listChunks(assemblyId) {
|
|
1055
|
+
_validateAssemblyId(assemblyId);
|
|
1056
|
+
var picked = _pickBackend(_pickOpts());
|
|
1057
|
+
if (typeof picked.backend.list !== "function") {
|
|
1058
|
+
throw _err("UNSUPPORTED",
|
|
1059
|
+
"chunkScratch.listChunks: backend '" + picked.backend.name + "' does not implement list()", true);
|
|
1060
|
+
}
|
|
1061
|
+
var prefix = rootKeyPrefix + "/" + assemblyId + "/";
|
|
1062
|
+
var listRes = await picked.backend.list(prefix);
|
|
1063
|
+
var items = listRes && Array.isArray(listRes.items) ? listRes.items
|
|
1064
|
+
: Array.isArray(listRes) ? listRes : [];
|
|
1065
|
+
var indices = [];
|
|
1066
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1067
|
+
// Backends return either { key, size, lastModified } objects
|
|
1068
|
+
// (local + S3 + GCS) or bare key strings. Normalize.
|
|
1069
|
+
var item = items[i];
|
|
1070
|
+
var rawKey = typeof item === "string" ? item : item && item.key;
|
|
1071
|
+
if (typeof rawKey !== "string") continue;
|
|
1072
|
+
// The local backend's `list(prefix)` returns keys relative to
|
|
1073
|
+
// the prefix; cloud backends return absolute keys. Normalize
|
|
1074
|
+
// by stripping the prefix when present.
|
|
1075
|
+
var base = rawKey.indexOf(prefix) === 0 ? rawKey.slice(prefix.length) : rawKey;
|
|
1076
|
+
if (base === ".meta" || base.indexOf("/") !== -1) continue;
|
|
1077
|
+
if (!/^[0-9]{1,8}\.chunk$/.test(base)) continue;
|
|
1078
|
+
indices.push(parseInt(base.slice(0, -6), 10));
|
|
1079
|
+
}
|
|
1080
|
+
indices.sort(function (a, b) { return a - b; });
|
|
1081
|
+
return indices;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function countChunks(assemblyId) {
|
|
1085
|
+
var indices = await listChunks(assemblyId);
|
|
1086
|
+
return indices.length;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async function removeChunk(args) {
|
|
1090
|
+
_validateAssemblyId(args.assemblyId);
|
|
1091
|
+
_validateChunkIndex(args.chunkIndex);
|
|
1092
|
+
return deleteFile(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function assemble(args) {
|
|
1096
|
+
if (!args || typeof args !== "object") {
|
|
1097
|
+
throw _err("INVALID_ARGUMENT", "chunkScratch.assemble: args must be an object", true);
|
|
1098
|
+
}
|
|
1099
|
+
_validateAssemblyId(args.assemblyId);
|
|
1100
|
+
if (!Array.isArray(args.chunkEncryptionKeys) || args.chunkEncryptionKeys.length === 0) {
|
|
1101
|
+
throw _err("INVALID_ARGUMENT",
|
|
1102
|
+
"chunkScratch.assemble: chunkEncryptionKeys must be a non-empty array (one per chunk in order)", true);
|
|
1103
|
+
}
|
|
1104
|
+
var indices = await listChunks(args.assemblyId);
|
|
1105
|
+
if (typeof args.expectedTotal === "number" && indices.length !== args.expectedTotal) {
|
|
1106
|
+
throw _err("INCOMPLETE_ASSEMBLY",
|
|
1107
|
+
"chunkScratch.assemble: have " + indices.length + " chunks; expected " + args.expectedTotal, true);
|
|
1108
|
+
}
|
|
1109
|
+
if (indices.length !== args.chunkEncryptionKeys.length) {
|
|
1110
|
+
throw _err("INVALID_ARGUMENT",
|
|
1111
|
+
"chunkScratch.assemble: chunkEncryptionKeys.length (" + args.chunkEncryptionKeys.length +
|
|
1112
|
+
") must match chunk count (" + indices.length + ")", true);
|
|
1113
|
+
}
|
|
1114
|
+
// Verify monotonic 0..N-1 indices — no gaps.
|
|
1115
|
+
for (var i = 0; i < indices.length; i += 1) {
|
|
1116
|
+
if (indices[i] !== i) {
|
|
1117
|
+
throw _err("INCOMPLETE_ASSEMBLY",
|
|
1118
|
+
"chunkScratch.assemble: chunk gap at index " + i + " (found " + indices[i] + ")", true);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// Concatenate in order. Each chunk decrypts via its own envelope
|
|
1122
|
+
// key; the operator persisted the per-chunk key when saveChunk
|
|
1123
|
+
// returned it.
|
|
1124
|
+
var parts = [];
|
|
1125
|
+
var totalBytes = 0;
|
|
1126
|
+
for (var c = 0; c < indices.length; c += 1) {
|
|
1127
|
+
var buf = await getChunk({
|
|
1128
|
+
assemblyId: args.assemblyId,
|
|
1129
|
+
chunkIndex: c,
|
|
1130
|
+
encryptionKey: args.chunkEncryptionKeys[c],
|
|
1131
|
+
});
|
|
1132
|
+
parts.push(buf);
|
|
1133
|
+
totalBytes += buf.length;
|
|
1134
|
+
}
|
|
1135
|
+
_emit("system.storage.chunk_scratch.assembled", {
|
|
1136
|
+
metadata: {
|
|
1137
|
+
assemblyId: args.assemblyId,
|
|
1138
|
+
chunkCount: indices.length,
|
|
1139
|
+
sizeBytes: totalBytes,
|
|
1140
|
+
},
|
|
1141
|
+
});
|
|
1142
|
+
return Buffer.concat(parts, totalBytes);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
async function removeAssembly(assemblyId) {
|
|
1146
|
+
_validateAssemblyId(assemblyId);
|
|
1147
|
+
var indices = await listChunks(assemblyId);
|
|
1148
|
+
var removed = 0;
|
|
1149
|
+
for (var i = 0; i < indices.length; i += 1) {
|
|
1150
|
+
try { await removeChunk({ assemblyId: assemblyId, chunkIndex: indices[i] }); removed += 1; }
|
|
1151
|
+
catch (_e) { /* best-effort */ }
|
|
1152
|
+
}
|
|
1153
|
+
_emit("system.storage.chunk_scratch.removed", {
|
|
1154
|
+
metadata: { assemblyId: assemblyId, chunksRemoved: removed },
|
|
1155
|
+
});
|
|
1156
|
+
return { chunksRemoved: removed };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function listAssemblies() {
|
|
1160
|
+
var picked = _pickBackend(_pickOpts());
|
|
1161
|
+
if (typeof picked.backend.list !== "function") {
|
|
1162
|
+
throw _err("UNSUPPORTED",
|
|
1163
|
+
"chunkScratch.listAssemblies: backend '" + picked.backend.name + "' does not implement list()", true);
|
|
1164
|
+
}
|
|
1165
|
+
var listRes = await picked.backend.list(rootKeyPrefix + "/");
|
|
1166
|
+
var items = listRes && Array.isArray(listRes.items) ? listRes.items
|
|
1167
|
+
: Array.isArray(listRes) ? listRes : [];
|
|
1168
|
+
var ids = {};
|
|
1169
|
+
var prefixWithSlash = rootKeyPrefix + "/";
|
|
1170
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1171
|
+
var item = items[i];
|
|
1172
|
+
var rawKey = typeof item === "string" ? item : item && item.key;
|
|
1173
|
+
if (typeof rawKey !== "string") continue;
|
|
1174
|
+
var rel = rawKey.indexOf(prefixWithSlash) === 0
|
|
1175
|
+
? rawKey.slice(prefixWithSlash.length) : rawKey;
|
|
1176
|
+
var slash = rel.indexOf("/");
|
|
1177
|
+
if (slash === -1) continue;
|
|
1178
|
+
ids[rel.slice(0, slash)] = true;
|
|
1179
|
+
}
|
|
1180
|
+
return canonicalJson.sortKeys(ids);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function listStaleAssemblies(args) {
|
|
1184
|
+
args = args || {};
|
|
1185
|
+
var olderThan = (typeof args.olderThanMs === "number" && args.olderThanMs > 0)
|
|
1186
|
+
? args.olderThanMs : staleAfterMs;
|
|
1187
|
+
var cutoff = Date.now() - olderThan;
|
|
1188
|
+
var picked = _pickBackend(_pickOpts());
|
|
1189
|
+
if (typeof picked.backend.list !== "function") {
|
|
1190
|
+
throw _err("UNSUPPORTED",
|
|
1191
|
+
"chunkScratch.listStaleAssemblies: backend does not implement list()", true);
|
|
1192
|
+
}
|
|
1193
|
+
var assemblies = await listAssemblies();
|
|
1194
|
+
var stale = [];
|
|
1195
|
+
for (var i = 0; i < assemblies.length; i += 1) {
|
|
1196
|
+
var assemblyId = assemblies[i];
|
|
1197
|
+
// Use the earliest chunk's mtime as the assembly's createdAt
|
|
1198
|
+
// proxy. Backends that surface mtime via list() inspect items;
|
|
1199
|
+
// others fall through to a stat probe on the first chunk.
|
|
1200
|
+
var indices = await listChunks(assemblyId);
|
|
1201
|
+
if (indices.length === 0) { stale.push(assemblyId); continue; }
|
|
1202
|
+
var firstKey = _chunkKey(assemblyId, indices[0]);
|
|
1203
|
+
var stat = null;
|
|
1204
|
+
if (typeof picked.backend.stat === "function") {
|
|
1205
|
+
try { stat = await picked.backend.stat(firstKey); } catch (_e) { stat = null; }
|
|
1206
|
+
}
|
|
1207
|
+
var mtime = stat && (stat.mtimeMs || (stat.mtime && stat.mtime.getTime && stat.mtime.getTime()));
|
|
1208
|
+
if (typeof mtime === "number" && mtime < cutoff) {
|
|
1209
|
+
stale.push(assemblyId);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return stale;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function gc(args) {
|
|
1216
|
+
args = args || {};
|
|
1217
|
+
var stale = await listStaleAssemblies({ olderThanMs: args.olderThanMs });
|
|
1218
|
+
var removed = [];
|
|
1219
|
+
for (var i = 0; i < stale.length; i += 1) {
|
|
1220
|
+
try {
|
|
1221
|
+
var r = await removeAssembly(stale[i]);
|
|
1222
|
+
removed.push({ assemblyId: stale[i], chunksRemoved: r.chunksRemoved });
|
|
1223
|
+
} catch (_e) { /* best-effort GC */ }
|
|
1224
|
+
}
|
|
1225
|
+
_emit("system.storage.chunk_scratch.gc", {
|
|
1226
|
+
metadata: { staleCount: stale.length, removedCount: removed.length },
|
|
1227
|
+
});
|
|
1228
|
+
return { removed: removed };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
return {
|
|
1232
|
+
saveChunk: saveChunk,
|
|
1233
|
+
getChunk: getChunk,
|
|
1234
|
+
chunkExists: chunkExists,
|
|
1235
|
+
listChunks: listChunks,
|
|
1236
|
+
countChunks: countChunks,
|
|
1237
|
+
removeChunk: removeChunk,
|
|
1238
|
+
assemble: assemble,
|
|
1239
|
+
removeAssembly: removeAssembly,
|
|
1240
|
+
listAssemblies: listAssemblies,
|
|
1241
|
+
listStaleAssemblies: listStaleAssemblies,
|
|
1242
|
+
gc: gc,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
830
1246
|
function _resetForTest() {
|
|
831
1247
|
initialized = false;
|
|
832
1248
|
backends = {};
|
|
@@ -851,5 +1267,6 @@ module.exports = {
|
|
|
851
1267
|
presignedUploadPolicy: presignedUploadPolicy,
|
|
852
1268
|
listBackends: listBackends,
|
|
853
1269
|
getBackend: getBackend,
|
|
1270
|
+
chunkScratch: chunkScratch,
|
|
854
1271
|
_resetForTest: _resetForTest,
|
|
855
1272
|
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:b87538c7-3bfe-497b-aa53-9191876a4e1f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-15T19:42:01.531Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.45",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.45",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.45",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.45",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|