@blamejs/core 0.8.67 → 0.8.68

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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.68 (2026-05-10) — `b.watcher` polling backend for environments where `fs.watch` doesn't deliver events. Pre-v0.8.68 the watcher used `fs.watch(root, { recursive: true })` exclusively — works on real Linux/macOS/Windows kernels but silent on filesystems where the kernel→userspace event bridge doesn't exist: Docker Desktop bind-mounts on Windows / macOS hosts (gRPC-FUSE / VirtioFS doesn't propagate inotify events from the Linux container's mount through to the host fs the operator runs node on), NFS / SMB mounts, some FUSE filesystems. Add `mode: "fs" | "poll"` (default `"fs"`) — when `mode: "poll"`, the watcher walks the tree on a fixed interval and diffs against the previous snapshot. New file / mtime-change / size-change → `onChange` via the existing debounce + ignore + lstat dispatch; missing path → `onDelete`. `pollIntervalMs` (default 1s) sets cadence; `pollMaxFiles` (default 50000) caps the per-tick walk so a misconfigured root can't stall the event loop stat'ing 100k files every second — overflow refuses with `watcher/poll-overflow`. Symlinks skipped (matches fs.watch path). The initial walk happens synchronously in `create()` so the first event fires only on real post-start changes (not on pre-existing files). `_flushForTest()` runs one synchronous tick + drains pending debounces so polling tests don't have to sleep `pollIntervalMs`. Returned handle gains `.mode` for operator introspection. fs.watch-backend error messages now suggest `mode: "poll"` as the fallback. New Layer 0 polling tests cover create / modify / delete detection across nested directories + mode validation + pollMaxFiles overflow.
11
12
  - v0.8.67 (2026-05-10) — SAML XMLDSig Reference Transforms (`enveloped-signature` + per-Reference c14n) + full IdP-emitted SAML round-trip in the federation-auth integration test. Pre-v0.8.67 `b.auth.saml.sp.verifyResponse` only honored the SignedInfo's `CanonicalizationMethod`; it didn't process the `<ds:Transforms>` block on the Reference. Real-world IdP-signed responses (Keycloak, ADFS, Okta) attach `http://www.w3.org/2000/09/xmldsig#enveloped-signature` (strip the `<Signature>` child of the referenced element before c14n) and a per-Reference `xml-exc-c14n#` Transform — without them, the digest computed over the assertion-including-signature never matches the signed-then-signature-injected reality and verifyResponse rejected legitimate IdP responses. `_verifyXmldsig` now reads the Transforms list, applies enveloped-signature by filtering the parsed-tree's `<Signature>` element children before canonicalization, and honors the per-Reference c14n choice (with vs without comments). The single-match-by-ID invariant + signature-wrapping defense moves into the saml.js path directly so the modified subtree (signature stripped) is the one that gets canonicalized + digested. `test/integration/federation-auth.test.js` now drives Keycloak's HTML login form via cookie-jar curl-equivalent (no headless browser needed), captures the IdP-signed SAMLResponse, fetches the IdP signing certificate from `/protocol/saml/descriptor`, hands the response to `sp.verifyResponse(b64, { expectedInResponseTo })`, and asserts the extracted `nameId` / `issuer` / `audience` / `inResponseTo` match the realm's signed claims. Verified end-to-end: Keycloak alice user → SAML AuthnRequest → login form POST → signed Response → `sp.verifyResponse()` → `{ nameId: "alice", issuer: <realm>, audience: <SP entityID>, attributes: { Role: "default-roles-..." } }`. Unsupported Transform algorithms refuse loudly via `auth-saml/unsupported-transform`. No primitive surface change versus v0.8.66 (the Transforms processing is internal to `_verifyXmldsig`).
12
13
  - v0.8.66 (2026-05-10) — `b.session.updateData(token, data, opts?)`. Update the sealed `data` payload on a session WITHOUT rotating the sid. Pre-v0.8.66 the only path to mutate session data was `b.session.rotate(token, { data })` which forces an sid rotation — appropriate for security-boundary transitions (login, MFA, role escalation) but heavyweight for cart-state writes / preference flips / step-up-completion flags. Default semantics: full payload replace, `lastActivity` bumped (idle-timeout reset), reserved `__bj_fingerprint` binding preserved automatically so verify() still surfaces drift correctly. `opts.merge: true` does a one-level deep merge into the existing payload; `opts.touchLastActivity: false` skips the idle-timeout bump. Returns `false` for unknown / expired / pre-v0.8.61-raw-format tokens (no throw). Anonymous-session userIds work the same as named userIds. Leader-only. New Layer 0 tests: 13 checks covering replace / merge / null-clear / fingerprint preservation / unknown-token returns / array refusal.
13
14
  - v0.8.65 (2026-05-10) — federated-authentication integration test fixture (Keycloak as OIDC OP + SAML IdP). Adds `quay.io/keycloak/keycloak:26.0` to `docker-compose.test.yml` running with realm-import on port `:18080` (HTTP) + `:18081` (Quarkus health). Realm `blamejs-test` boots with one OIDC client (`blamejs-rp-oidc`, secret `blamejs-test-rp-secret`, frontchannel + backchannel logout enabled), one SAML SP client (entityID `https://sp.blamejs-test.example`, RSA-SHA256 assertion signature), and a test user (`alice` / `blamejs-test-password`). New `test/integration/federation-auth.test.js` exercises end-to-end against the live Keycloak: OIDC discovery, `b.auth.oauth.authorizationUrl` (state + nonce + PKCE), password-grant token retrieval, `verifyIdToken` against the realm JWKS, `fetchUserInfo` with `idTokenSub` cross-check, RP-Initiated Logout URL build, `parseFrontchannelLogoutRequest` iss-mismatch refusal, `verifyBackchannelLogoutToken` JWKS-lookup + signature + typ-check failure paths, SAML `buildAuthnRequest` POST to the IdP's `/protocol/saml` endpoint, SP `metadata()` XML emit, and CIBA `startAuthentication` wire-format check (Keycloak's `backchannel_authentication_endpoint` is at `/protocol/openid-connect/ext/ciba/auth`). `b.auth.ciba._postForm` now sets `responseMode: "always-resolve"` so deterministic OAuth-shape error JSON (`{ error, error_description }`) reaches the AuthError-mapping path instead of being rejected as a generic HTTP error. `b.auth.ciba._postForm` URL validation switched from a non-existent `safeUrl.assertHttpUrl` to `safeUrl.parse({ allowedProtocols })`. `scripts/check-services.js` registers `keycloak` + `keycloak-health` (both v4 + v6); `test/helpers/services.js` exposes `URLS.keycloak`. CLAUDE.md release-workflow §6 documents the federation-auth integration test path. OID4VCI / OID4VP / OpenID Federation deferred — Keycloak's `oid4vc-issuer` SPI is preview-only and there's no entity-statement publisher in the base image.
package/lib/watcher.js CHANGED
@@ -55,6 +55,17 @@ var audit = lazyRequire(function () { return require("./audit"); });
55
55
  var observability = lazyRequire(function () { return require("./observability"); });
56
56
 
57
57
  var DEFAULT_DEBOUNCE_MS = 100;
58
+ // Polling-mode defaults. The polling backend exists for environments
59
+ // where fs.watch's native events don't reach userspace — most commonly
60
+ // Docker Desktop bind-mounts on Windows / macOS hosts (where the
61
+ // inotify events from the Linux container's mount don't propagate
62
+ // through the gRPC-FUSE / VirtioFS bridge to the host fs), or NFS /
63
+ // SMB mounts that don't fire change notifications. Operators opt in
64
+ // explicitly via `mode: "poll"`. Default cadence is 1s per tick;
65
+ // pollMaxFiles caps the per-tick walk so a misconfigured root can't
66
+ // stall the event loop by stat'ing 100k files every second.
67
+ var DEFAULT_POLL_INTERVAL_MS = 1000; // allow:raw-byte-literal — 1-second poll cadence
68
+ var DEFAULT_POLL_MAX_FILES = 50000; // allow:raw-byte-literal — per-tick stat cap
58
69
  // Per-watcher event count cap before we self-terminate as a safety net
59
70
  // against runaway directories that emit millions of events per minute.
60
71
  // Operators with legitimate high-churn directories raise this via opts.
@@ -167,10 +178,23 @@ function _compileIgnore(patterns) {
167
178
  };
168
179
  }
169
180
 
181
+ var ALLOWED_MODES = ["fs", "poll"];
182
+
170
183
  function _validateOpts(opts) {
171
184
  validateOpts.requireObject(opts, "watcher.create", WatcherError, "watcher/bad-opts");
172
185
  validateOpts.requireNonEmptyString(opts.root, "root", WatcherError, "watcher/bad-root");
173
186
  validateOpts.optionalFiniteNonNegative(opts.debounceMs, "debounceMs", WatcherError, "watcher/bad-debounce-ms");
187
+ if (opts.mode !== undefined && ALLOWED_MODES.indexOf(opts.mode) === -1) {
188
+ throw new WatcherError("watcher/bad-mode",
189
+ "watcher.create: mode must be one of " + ALLOWED_MODES.join(", ") +
190
+ ", got " + JSON.stringify(opts.mode));
191
+ }
192
+ validateOpts.optionalPositiveFinite(opts.pollIntervalMs, "pollIntervalMs", WatcherError, "watcher/bad-poll-interval-ms");
193
+ if (opts.pollMaxFiles !== undefined &&
194
+ (typeof opts.pollMaxFiles !== "number" || !isFinite(opts.pollMaxFiles) || opts.pollMaxFiles < 1)) {
195
+ throw new WatcherError("watcher/bad-poll-max-files",
196
+ "watcher.create: pollMaxFiles must be a positive finite integer");
197
+ }
174
198
  if (opts.maxPending !== undefined &&
175
199
  (typeof opts.maxPending !== "number" || !isFinite(opts.maxPending) || opts.maxPending < 1)) {
176
200
  throw new WatcherError("watcher/bad-max-pending",
@@ -191,6 +215,9 @@ function create(opts) {
191
215
  var root = path.resolve(opts.root);
192
216
  var debounceMs = (opts.debounceMs !== undefined) ? opts.debounceMs : DEFAULT_DEBOUNCE_MS;
193
217
  var maxPending = (opts.maxPending !== undefined) ? opts.maxPending : DEFAULT_MAX_PENDING;
218
+ var mode = opts.mode || "fs";
219
+ var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
220
+ var pollMaxFiles = opts.pollMaxFiles || DEFAULT_POLL_MAX_FILES;
194
221
  var onChange = opts.onChange || function () {};
195
222
  var onDelete = opts.onDelete || function () {};
196
223
  var onError = opts.onError || function () {};
@@ -292,41 +319,126 @@ function create(opts) {
292
319
  pending.set(relPath, entry);
293
320
  }
294
321
 
295
- // ---- start the underlying watch ----
296
- try {
297
- watcherHandle = fs.watch(root, { recursive: true, persistent: true }, function (eventType, filename) {
298
- if (stopped) return;
299
- // filename can be null on some platforms when the buffer
300
- // overflows. Drop — there is nothing actionable.
301
- if (!filename) return;
302
- // node returns OS-native paths; normalize to root-relative.
303
- var rel = filename;
304
- // fs.watch passes a relative path already, but on macOS it can
305
- // be an absolute path under /private/var/... when the root is a
306
- // tmpdir symlink. Strip the root prefix defensively.
307
- if (path.isAbsolute(rel) && rel.indexOf(root) === 0) {
308
- rel = path.relative(root, rel);
322
+ // ---- start the underlying backend ----
323
+ // pollSnapshot lives at function scope so stop() and _flushForTest()
324
+ // can reach the polling tick state.
325
+ var pollTimer = null;
326
+ var pollSnapshot = null; // Map<relPath, { type, size, mtimeMs }>
327
+
328
+ // Walk the tree honoring `ignore` patterns + the pollMaxFiles cap.
329
+ // Returns the new snapshot Map, OR throws watcher/poll-overflow when
330
+ // the cap is hit (that's an operator-misconfigured root signal — a
331
+ // 100k-file tree under a 1s polling cadence stalls the event loop).
332
+ function _walkPollTree() {
333
+ var snapshot = new Map();
334
+ var fileCount = 0;
335
+ var stack = [""];
336
+ while (stack.length > 0) {
337
+ var relDir = stack.pop();
338
+ var absDir = relDir === "" ? root : path.join(root, relDir);
339
+ var entries;
340
+ try { entries = fs.readdirSync(absDir, { withFileTypes: true }); }
341
+ catch (_e) {
342
+ // Root vanished mid-walk OR an inner dir got deleted between
343
+ // the parent listing and the descent. Skip — the next tick's
344
+ // walk surfaces the deletion via the snapshot diff.
345
+ continue;
346
+ }
347
+ for (var i = 0; i < entries.length; i += 1) {
348
+ var entry = entries[i];
349
+ var relPath = relDir === "" ? entry.name : (relDir + "/" + entry.name);
350
+ // Normalize to forward-slash so glob ignore-matching is
351
+ // consistent with the fs.watch path the operator's hooks see.
352
+ relPath = relPath.split(path.sep).join("/");
353
+ if (isIgnored(relPath)) continue;
354
+ if (entry.isSymbolicLink()) continue; // never follow symlinks
355
+ fileCount += 1;
356
+ if (fileCount > pollMaxFiles) {
357
+ throw new WatcherError("watcher/poll-overflow",
358
+ "watcher.poll: tree exceeds pollMaxFiles=" + pollMaxFiles +
359
+ " — narrow `ignore` patterns OR raise pollMaxFiles, OR switch to mode: \"fs\"");
360
+ }
361
+ var absPath = path.join(absDir, entry.name);
362
+ var st;
363
+ try { st = fs.statSync(absPath); }
364
+ catch (_e) { continue; } // race — entry vanished
365
+ if (entry.isDirectory()) {
366
+ snapshot.set(relPath, { type: "dir", size: 0, mtimeMs: st.mtimeMs });
367
+ stack.push(relPath);
368
+ } else if (entry.isFile()) {
369
+ snapshot.set(relPath, { type: "file", size: st.size, mtimeMs: st.mtimeMs });
370
+ }
371
+ // Other kinds (sockets, FIFOs, devices) — skip.
372
+ }
373
+ }
374
+ return snapshot;
375
+ }
376
+
377
+ function _pollTick() {
378
+ if (stopped) return;
379
+ var next;
380
+ try { next = _walkPollTree(); }
381
+ catch (e) { _safeError(e); return; }
382
+ if (pollSnapshot === null) {
383
+ // First tick — establish the baseline without firing events.
384
+ // Operators get add events on file CREATION after start, not on
385
+ // pre-existing files (matches fs.watch semantics).
386
+ pollSnapshot = next;
387
+ return;
388
+ }
389
+ // Diff: anything in `next` not in `pollSnapshot`, OR with size /
390
+ // mtimeMs different, fires onChange via the same _enqueue path the
391
+ // fs.watch backend uses (so debounce + ignore + lstat dispatch
392
+ // stay uniform). Anything in `pollSnapshot` missing from `next`
393
+ // fires onDelete (via _normalizeAndDispatch's ENOENT branch).
394
+ next.forEach(function (info, relPath) {
395
+ var prev = pollSnapshot.get(relPath);
396
+ if (!prev) { _enqueue(relPath); return; }
397
+ if (prev.size !== info.size || prev.mtimeMs !== info.mtimeMs || prev.type !== info.type) {
398
+ _enqueue(relPath);
309
399
  }
310
- // Both inotify and ReadDirectoryChangesW occasionally fire with
311
- // an empty filename for the root directory itself — ignore.
312
- if (rel === "" || rel === ".") return;
313
- _enqueue(rel);
314
400
  });
315
- watcherHandle.on("error", function (err) { _safeError(err); });
316
- } catch (e) {
317
- // Older kernels without recursive inotify return ERR_FEATURE_UNAVAILABLE.
318
- // Surface as an operator-actionable error rather than a silent
319
- // single-directory degradation.
320
- if (e && (e.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" || e.code === "ENOSYS")) {
321
- throw new WatcherError("watcher/recursive-unsupported",
322
- "watcher.create: recursive watch not supported on this platform/kernel: " +
323
- ((e && e.message) || String(e)));
401
+ pollSnapshot.forEach(function (_info, relPath) {
402
+ if (!next.has(relPath)) _enqueue(relPath);
403
+ });
404
+ pollSnapshot = next;
405
+ }
406
+
407
+ if (mode === "poll") {
408
+ // Establish the initial snapshot synchronously so the first
409
+ // operator-side onChange fires only on real post-start changes.
410
+ try { pollSnapshot = _walkPollTree(); }
411
+ catch (e) {
412
+ throw new WatcherError("watcher/start-failed",
413
+ "watcher.create: initial poll walk failed: " + ((e && e.message) || String(e)));
414
+ }
415
+ pollTimer = setInterval(_pollTick, pollIntervalMs); // allow:setinterval-unref — .unref() called immediately below; timer doesn't pin the event loop
416
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
417
+ } else {
418
+ try {
419
+ watcherHandle = fs.watch(root, { recursive: true, persistent: true }, function (eventType, filename) {
420
+ if (stopped) return;
421
+ if (!filename) return;
422
+ var rel = filename;
423
+ if (path.isAbsolute(rel) && rel.indexOf(root) === 0) {
424
+ rel = path.relative(root, rel);
425
+ }
426
+ if (rel === "" || rel === ".") return;
427
+ _enqueue(rel);
428
+ });
429
+ watcherHandle.on("error", function (err) { _safeError(err); });
430
+ } catch (e) {
431
+ if (e && (e.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" || e.code === "ENOSYS")) {
432
+ throw new WatcherError("watcher/recursive-unsupported",
433
+ "watcher.create: recursive watch not supported on this platform/kernel: " +
434
+ ((e && e.message) || String(e)) + " — pass mode: \"poll\" to fall back to interval polling");
435
+ }
436
+ throw new WatcherError("watcher/start-failed",
437
+ "watcher.create: fs.watch failed: " + ((e && e.message) || String(e)));
324
438
  }
325
- throw new WatcherError("watcher/start-failed",
326
- "watcher.create: fs.watch failed: " + ((e && e.message) || String(e)));
327
439
  }
328
440
 
329
- _safeEmitAudit("watcher.started", { root: root });
441
+ _safeEmitAudit("watcher.started", { root: root, mode: mode });
330
442
 
331
443
  function stop() {
332
444
  if (stopped) return;
@@ -340,13 +452,23 @@ function create(opts) {
340
452
  try { watcherHandle.close(); } catch (_e) { /* best-effort */ }
341
453
  watcherHandle = null;
342
454
  }
343
- _safeEmitAudit("watcher.stopped", { root: root, eventCount: eventCount });
455
+ if (pollTimer) {
456
+ try { clearInterval(pollTimer); } catch (_e) { /* best-effort */ }
457
+ pollTimer = null;
458
+ }
459
+ _safeEmitAudit("watcher.stopped", { root: root, mode: mode, eventCount: eventCount });
344
460
  }
345
461
 
346
462
  // Test seam — flushes all pending debounce timers immediately so
347
463
  // tests don't have to await debounceMs. Not part of the operator
348
- // contract.
464
+ // contract. In poll mode, also synchronously runs one tick so a
465
+ // test can write a file, call _flushForTest(), and observe the
466
+ // resulting onChange without sleeping for pollIntervalMs.
349
467
  function _flushForTest() {
468
+ if (mode === "poll" && !stopped) {
469
+ try { _pollTick(); }
470
+ catch (_e) { /* tests assert via the operator's onChange callback */ }
471
+ }
350
472
  var snapshot = Array.from(pending.entries());
351
473
  pending.clear();
352
474
  for (var i = 0; i < snapshot.length; i += 1) {
@@ -358,6 +480,7 @@ function create(opts) {
358
480
  return {
359
481
  stop: stop,
360
482
  root: root,
483
+ mode: mode,
361
484
  _flushForTest: _flushForTest,
362
485
  };
363
486
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.67",
3
+ "version": "0.8.68",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:7b747b8f-4f21-448f-a73e-50b8615f6937",
5
+ "serialNumber": "urn:uuid:474443de-ab7d-4d50-8f5f-a5c080068475",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-10T14:43:14.112Z",
8
+ "timestamp": "2026-05-10T14:52:18.655Z",
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.8.67",
22
+ "bom-ref": "@blamejs/core@0.8.68",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.67",
25
+ "version": "0.8.68",
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.8.67",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.68",
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.8.67",
57
+ "ref": "@blamejs/core@0.8.68",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]