cross-tab-worker-databus 0.21.41 → 0.21.43

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
@@ -1,3 +1,28 @@
1
+ ## [0.21.43] - 2026-09-26
2
+
3
+ Two claims about the storage boundary turned out to be narrower than the storage was, and a public clock could report `NaN` into the health summary. No library behavior, export, option, default, frame or storage key changed beyond the one guard; the coverage floors and the zero-count arm ledger are `0.21.42`'s.
4
+
5
+ ### Fixed
6
+
7
+ - **A caller-injected dedup clock that returns a non-finite number poisoned every comparison that reads it.** `dedup.now` is a public option, and a non-wall-clock host is exactly what it exists for — one whose reading can be *missing* rather than merely unusual. Since every comparison in the dedup manager is arithmetic on a timestamp, a single `NaN` reading did not degrade one field: the adaptive window's `elapsed >= ADAPTIVE_WINDOW_MS` never fired, the hot path's `now - timestamp > ttlMs` became `NaN > ttlMs` (false, so nothing expired), the periodic sweep pruned nothing, and `getDedupStats().ttlMs` reported **`NaN`** — which `JSON.stringify` writes as **`null`**, so a health summary or a diagnostics log carried a null where a duration belongs. Measured on the pre-fix class: `ttlMs` → `NaN`, serialized → `{"ttlMs":null}`, and a message ID stayed suppressed for as long as it stayed in the map. The manager now holds the last finite reading, which is the degradation that changes least — time stands still, expiry pauses, the adaptive window keeps its last value — where substituting `Date.now()` would silently mix two clocks and zeroing would expire the whole map on the next call. The guard sits at the clock rather than at each comparison, which is the shape this repository already uses for the same class of input in `trace.ts`, `websocket.ts` and `replay-manager.ts`. Pinned by two cases whose legs fail apart: removing the guard fails both, while removing only the constructor's fallback — the one site with no previous reading to hold — fails the second alone, because a `NaN` baseline would make every later `elapsed` non-finite too. `docs/api.md` and its Chinese mirror state the guarantee where `dedup.now` is documented.
8
+
9
+ ### Added
10
+
11
+ - **The storage boundary is now stated where it was widest, not only where it was found.** `docs/configuration.md` and `docs/architecture.md` each said the coordination records are isolated per `clusterKey` and that Topic plaintext reaches a durable store only through two named opt-ins; the architecture guide carried that claim in a *dedicated* section ("Two DataBus instances with different `clusterKey` values … operate on completely isolated storage namespaces") and as an absolute above its key listing ("All keys are isolated by `createOpaqueKey(clusterKey)`"). Both now name the exception the second opt-in introduces — its IndexedDB object store is keyed by the plaintext Topic and its database name defaults to the storage prefix, so it is not partitioned by `clusterKey` at all, and `dbName` per cluster is what separates them — in both languages.
12
+ - **The key/plaintext pairing rule is now described the way the code enforces it.** The protocol table's row "`topicKey === createOpaqueKey(topic)`" became unconditionally true in `0.21.42`, but the prose beneath it still said only that a pair which *disagrees* is a substitution — which reads as a rule about mismatches and leaves a frame carrying no `topicKey` sounding like a tolerated legacy shape, which is the reading that once kept a guard in place. The shipped text now says the comparison is unconditional, that a missing field is a disagreement of the same kind, and that a custom environment should always send the field. Both languages.
13
+
14
+ ## [0.21.42] - 2026-09-26
15
+
16
+ The library's central privacy claim — that a topic's plaintext never reaches localStorage — was enforced on keys by accident and on values by nothing, and the guard that protects the coordination plane's key/topic pair was written to tolerate a missing field it never had to tolerate. Both halves of that sentence were found by re-auditing claims rather than code: the first by measuring which mutations anything noticed, the second by enumerating the readers of a pair. One behavioral change, no API, option, default, frame or storage-key change.
17
+
18
+ ### Fixed
19
+
20
+ - **A forged `CONTROL` frame carrying no `topicKey` skipped the check that is supposed to stop a forged one.** `handleControlMessage` guarded the pairing comparison with `message.topicKey !== undefined && …`, so a frame with the field absent passed; `handleRouteReleasedMessage` — the other reader of the same pair, and the one that *completes* a handoff — compares unconditionally. `AGENTS.md` stated that both handlers enforce the invariant, so the claim held on one reader and not the other, the same shape as `0.21.5`, when the check existed only in `handleControlMessage` and the release note described it as if it covered the channel. The tolerance had no referent to protect: `topicKey` has been a *required* field of this message variant since the initial commit, so no released version ever omitted it, and the clause arrived in the same commit that added the check rather than being inherited from a legacy peer — unlike the optional `protocolVersion` beside it, which is a real legacy tolerance. Measured before the fix, by writing the case first: the keyless frame reached the control dispatch, put the attacker's plaintext into `assignedTopics` and `knownTopics` under the key `undefined`, and made `getSnapshot().assignedTopics` report a topic the tab never subscribed to until the next reconcile swept it. No durable ownership is minted and nothing is written to storage — there is no route under that key — so the cost is a lying public snapshot for one tick, which is why this is a patch. The frame a same-origin script can post is the same unauthenticated channel as every other coordination frame, so a forged frame naming a topic with no key is a shape the receiver had to reject rather than adopt; a missing field is a disagreement, since `createOpaqueKey` returns 32 hex characters and `undefined` is none of them. Pinned by a case whose forged frame is widened at the call site (a per-field cast over a required field would typecheck, and the test suite does not type check), asserting all three surfaces the tolerance reached. Re-adding the clause fails that case and nothing else in the suite.
21
+
22
+ ### Added
23
+
24
+ - **The storage-plaintext invariant is now enforced over keys *and* values, and every durable write in `src/` is enumerated.** The multi-tab coordination sweep plants a per-seed marker in each topic name and searches the whole registry for it after every step rather than only on the quiesced end state — a record the next reconcile deletes again would leave the converged state clean while the plaintext was readable in between. Measured on three mutations, the two halves were not equally covered: a route record growing a `topic` field beside its `topicKey` failed **1 test in 969**, that arm alone, while a plaintext-bearing key also tripped two pre-existing cases that are about something else (a `clusterKey` namespace-containment assertion and the pagehide leftovers assertion) — keys had incidental coverage, values had none. `tests/storage-writers.test.ts` closes the remaining gap by enumerating every durable write in `src/` and re-deriving which of them carry a topic, so the two documented opt-ins (`channelFallback: 'storage-event'`, `replay.persistence`) are a checked list rather than a claim. Its topic half is there because the obvious word test was measured *passing* a leak that travelled through a variable named `topicKey`; the term that carries the weight is `knownTopics`, the one map that turns a hash back into a name. `docs/configuration.md` and its Chinese mirror now say the two opt-ins are the complete list and that it is checked.
25
+
1
26
  ## [0.21.41] - 2026-09-25
2
27
 
3
28
  The release that `0.21.39` was — a run that dies inside its own tag build, having published nothing — has no branch in the shipped release checklist, so the procedure that recovered it lived only in `AGENTS.md` and `docs/progress.md`, neither of which is in the tarball. No library behavior, export, option, default, frame or storage key changed; the coverage floors and the zero-count arm ledger are `0.21.40`'s.
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-37U5AL4M.js";
10
+ } from "./chunk-NTMMSR4W.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -1158,7 +1158,7 @@ var WorkerClusterRuntime = class {
1158
1158
  /** Handle a point-to-point CONTROL message (SUBSCRIBE / UNSUBSCRIBE / PUBLISH). */
1159
1159
  handleControlMessage(message) {
1160
1160
  if (message.targetWorkerId !== this.workerId) return;
1161
- if (message.topicKey !== void 0 && createOpaqueKey(message.topic) !== message.topicKey) return;
1161
+ if (createOpaqueKey(message.topic) !== message.topicKey) return;
1162
1162
  this.rememberTopic(message.topic);
1163
1163
  switch (message.action) {
1164
1164
  case CONTROL_ACTION.SUBSCRIBE: {
@@ -2366,6 +2366,8 @@ var DedupManager = class {
2366
2366
  seenMessageIds = /* @__PURE__ */ new Map();
2367
2367
  sweepTimer = null;
2368
2368
  windowStartedAt = 0;
2369
+ /** The most recent finite reading of `now`, and the fallback for a broken one. */
2370
+ lastFiniteNow = 0;
2369
2371
  windowAccepted = 0;
2370
2372
  suppressed = 0;
2371
2373
  accepted = 0;
@@ -2377,14 +2379,46 @@ var DedupManager = class {
2377
2379
  this.sweepMs = options.sweepMs;
2378
2380
  this.now = options.now;
2379
2381
  this.trace = options.trace;
2380
- this.windowStartedAt = this.now();
2382
+ this.lastFiniteNow = Number.isFinite(this.now()) ? this.now() : 0;
2383
+ this.windowStartedAt = this.lastFiniteNow;
2384
+ }
2385
+ /**
2386
+ * The injected clock, normalized.
2387
+ *
2388
+ * `dedup.now` is a public option and a non-wall-clock host is exactly the case it
2389
+ * exists for — one whose reading can be *missing* rather than merely unusual. Every
2390
+ * comparison this class makes is arithmetic on a timestamp, so a single non-finite
2391
+ * reading does not degrade one field: `now - timestamp > ttlMs` becomes
2392
+ * `NaN > ttlMs` (false, so nothing expires), the adaptive window's
2393
+ * `elapsed >= ADAPTIVE_WINDOW_MS` never fires, and `getStats().ttlMs` reports
2394
+ * `NaN` — which `JSON.stringify` turns into `null`, so a health summary or a
2395
+ * diagnostics log would carry a null where a duration belongs. Measured on the
2396
+ * pre-fix class: `getStats().ttlMs` → `NaN`, serialized → `null`, and an id
2397
+ * stayed suppressed for as long as it stayed in the map.
2398
+ *
2399
+ * Holding the last finite reading is the degradation that changes least: time
2400
+ * stands still, expiry pauses, and the adaptive window keeps the value it last
2401
+ * computed. Substituting `Date.now()` would be worse — it silently mixes two
2402
+ * clocks — and zeroing would expire the whole map on the next call. This is the
2403
+ * same class of guard `trace.ts` carries on its own public clock, and that
2404
+ * `websocket.ts` and `replay-manager.ts` apply to their numeric options; it is one
2405
+ * helper rather than a condition per site because the broken input is the *clock*,
2406
+ * not any particular comparison.
2407
+ */
2408
+ nowMs() {
2409
+ const reading = this.now();
2410
+ if (Number.isFinite(reading)) {
2411
+ this.lastFiniteNow = reading;
2412
+ return reading;
2413
+ }
2414
+ return this.lastFiniteNow;
2381
2415
  }
2382
2416
  /** True when a publication carrying `messageId` was already seen. Records the
2383
2417
  * ID and updates counters/trace on acceptance. Disabled or ID-less messages
2384
2418
  * always pass through. */
2385
2419
  isDuplicate(messageId, topic) {
2386
2420
  if (!this.enabled || !messageId) return false;
2387
- const now = this.now();
2421
+ const now = this.nowMs();
2388
2422
  const ttlMs = this.currentTtl();
2389
2423
  for (const [id, timestamp] of this.seenMessageIds) {
2390
2424
  if (now - timestamp > ttlMs) this.seenMessageIds.delete(id);
@@ -2435,12 +2469,12 @@ var DedupManager = class {
2435
2469
  this.seenMessageIds.clear();
2436
2470
  this.suppressed = 0;
2437
2471
  this.accepted = 0;
2438
- this.windowStartedAt = this.now();
2472
+ this.windowStartedAt = this.nowMs();
2439
2473
  this.windowAccepted = 0;
2440
2474
  }
2441
2475
  /** Remove IDs whose timestamp predates the effective TTL cutoff. */
2442
2476
  pruneExpired() {
2443
- const cutoff = this.now() - this.currentTtl();
2477
+ const cutoff = this.nowMs() - this.currentTtl();
2444
2478
  for (const [id, timestamp] of this.seenMessageIds) {
2445
2479
  if (timestamp < cutoff) this.seenMessageIds.delete(id);
2446
2480
  }
@@ -2451,7 +2485,7 @@ var DedupManager = class {
2451
2485
  * every ADAPTIVE_WINDOW_MS. */
2452
2486
  currentTtl() {
2453
2487
  if (!this.adaptiveBounds) return this.ttlMs;
2454
- const now = this.now();
2488
+ const now = this.nowMs();
2455
2489
  const elapsed = now - this.windowStartedAt;
2456
2490
  if (elapsed >= ADAPTIVE_WINDOW_MS) {
2457
2491
  this.windowStartedAt = now;
@@ -2475,7 +2509,7 @@ var SDK_VERSION = (
2475
2509
  // declaration's type is `string | undefined` and the export's is `string`
2476
2510
  // (dropping the conditional is TS2322, measured). Recorded here so the leg reads
2477
2511
  // as a shape the harness cannot reach rather than as a case nobody exercised.
2478
- true ? "0.21.41" : ""
2512
+ true ? "0.21.43" : ""
2479
2513
  );
2480
2514
 
2481
2515
  // src/core/data-bus.ts
@@ -3715,4 +3749,4 @@ export {
3715
3749
  parseDataBusPublication,
3716
3750
  selectWorkerBackend
3717
3751
  };
3718
- //# sourceMappingURL=chunk-37U5AL4M.js.map
3752
+ //# sourceMappingURL=chunk-NTMMSR4W.js.map