flagmint-js-sdk 2.2.2 → 2.2.3-rc-2

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/README.md CHANGED
@@ -72,7 +72,9 @@ await client.ready();
72
72
  const enabled = client.getFlag('new_dashboard', false);
73
73
  ```
74
74
 
75
- Same-origin iframes that use this API key share one SSE connection by default **in browsers that provide `BroadcastChannel`**. Without it (older browsers, Node), each client opens its own stream. Set `shareConnection: false` if two clients on the page must keep different evaluation contexts at the same time, or if the origin hosts untrusted documents that should not share that stream.
75
+ Same-origin iframes that use this API key share one SSE connection by default **in browsers that provide `BroadcastChannel`**. Without it (older browsers, Node), each client opens its own stream. Set `shareConnection: false` if the origin hosts untrusted documents that should not share that stream.
76
+
77
+ With **`configSync: true`**, sharing still uses one stream, but only **rules** are broadcast — each tab keeps its own context and evaluates locally (so agent A and agent B in sibling tabs stay independent). Classic server eval still shares evaluated flag maps (last context write wins on that path).
76
78
 
77
79
  ## Node.js
78
80
 
@@ -132,7 +134,7 @@ After `ready()`:
132
134
  | `context` | object | `{}` | Initial evaluation context. Do not put secrets or PII here — the stream URL serializes this object in the query string |
133
135
  | `transportMode` | `'auto' \| 'sse' \| 'long-polling'` | `'auto'` | `auto` tries SSE, then long-polling |
134
136
  | `EventSourceImpl` | EventSource | browser global | Required in Node |
135
- | `shareConnection` | boolean | `true` in browsers with `BroadcastChannel`, `false` in Node | One stream per API key across same-origin documents |
137
+ | `shareConnection` | boolean | `true` in browsers with `BroadcastChannel`, `false` in Node | One stream per API key across same-origin documents. With `configSync`, shares **rules** only (per-tab local eval). |
136
138
  | `enableOfflineCache` | boolean | `true` | Load/save flags via the cache adapter |
137
139
  | `persistContext` | boolean | `false` | Persist context in the cache adapter |
138
140
  | `cacheAdapter` | CacheAdapter | localStorage helpers | Custom cache (Redis, files, …) |
@@ -165,20 +167,32 @@ transportMode: 'long-polling' // POST evaluate on an interval
165
167
  Used when the same app is embedded twice on one origin (for example two iframes in a host page).
166
168
 
167
169
  - **Leader** performs handshake and holds EventSource.
168
- - **Followers** receive flag snapshots over `BroadcastChannel` (`flagmint-share:<apiKey>`). Leadership is a `localStorage` lock (`flagmint_share_lock:<apiKey>`).
169
- - Follower `updateContext()` is forwarded to the leader, which runs it on the shared stream. Last write wins. Any same-origin document can send that RPC and see the flags that come back — turn sharing off if you host untrusted content on this origin.
170
- - If the leader iframe unloads, it posts `bye` so another member can take over immediately. If the tab is killed without `pagehide`, followers wait for the lock TTL.
170
+ - **Followers** skip handshake/EventSource. Leadership is a `localStorage` lock.
171
+ - **Server eval:** followers receive evaluated maps on `flagmint-share:<apiKey>`; `updateContext` is forwarded to the leader (last write wins).
172
+ - **Config sync:** followers receive rules on `flagmint-share-rules:<apiKey>` and evaluate locally; context stays per tab.
173
+ - If the leader iframe unloads, it posts `bye` so another member can take over immediately. If the tab is killed without `pagehide`, followers wait for the lock TTL. Turn sharing off if you host untrusted content on this origin.
171
174
 
172
175
  ```ts
173
176
  // Default in browsers with BroadcastChannel — one stream for this API key
174
177
  shareConnection: true
175
178
 
176
- // Two clients, two contexts, two streams — or no BroadcastChannel
179
+ // Two independent streams (e.g. untrusted same-origin frames)
177
180
  shareConnection: false
181
+
182
+ // configSync + share: one SSE for rules; each tab evaluates with its own context
183
+ configSync: true
184
+ shareConnection: true // default when BroadcastChannel exists
178
185
  ```
179
186
 
180
187
  Node cluster workers do not share. Each worker process opens its own stream.
181
188
 
189
+ **Server eval vs config sync**
190
+
191
+ | Mode | What is shared | Context |
192
+ |------|----------------|---------|
193
+ | Server eval (`configSync` off) | Evaluated flag maps | Follower `updateContext` goes through the leader (last write wins) |
194
+ | Config sync | Verified **rules** snapshots only | Each tab’s own context; local `getFlag` / `updateContext` |
195
+
182
196
  ---
183
197
 
184
198
  # Updating context
@@ -39093,7 +39093,7 @@ class SseTransport {
39093
39093
  this.connectPromise = new Promise((resolve, reject) => {
39094
39094
  this.connectResolve = resolve;
39095
39095
  this.connectReject = reject;
39096
- const sdkVersion = "2.2.2";
39096
+ const sdkVersion = "2.2.3-rc-2";
39097
39097
  const platform2 = typeof window !== "undefined" ? "browser" : "nodejs";
39098
39098
  const wrapperName = this.configOptions?.wrapper?.name || "native-js";
39099
39099
  const wrapperVersion = this.configOptions?.wrapper?.version || "none";
@@ -39362,11 +39362,15 @@ class ConnectionShareHub {
39362
39362
  this.watchId = null;
39363
39363
  this.closed = false;
39364
39364
  this.latestFlags = null;
39365
+ this.latestRules = null;
39365
39366
  this.flagsWaiters = [];
39367
+ this.rulesWaiters = [];
39366
39368
  this.flagsTimeouts = [];
39369
+ this.rulesTimeouts = [];
39367
39370
  this.contextWaiters = /* @__PURE__ */ new Map();
39368
- this.lockKey = `flagmint_share_lock:${apiKey}`;
39369
- this.channelName = `flagmint-share:${apiKey}`;
39371
+ this.mode = options.mode ?? "evaluated";
39372
+ this.lockKey = this.mode === "rules" ? `flagmint_share_rules_lock:${apiKey}` : `flagmint_share_lock:${apiKey}`;
39373
+ this.channelName = this.mode === "rules" ? `flagmint-share-rules:${apiKey}` : `flagmint-share:${apiKey}`;
39370
39374
  this.now = options.now ?? (() => Date.now());
39371
39375
  this.storage = options.storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
39372
39376
  this.channelFactory = options.channelFactory;
@@ -39378,6 +39382,10 @@ class ConnectionShareHub {
39378
39382
  get currentRole() {
39379
39383
  return this.role;
39380
39384
  }
39385
+ /** `evaluated` (server eval) or `rules` (config sync). */
39386
+ get shareMode() {
39387
+ return this.mode;
39388
+ }
39381
39389
  /**
39382
39390
  * Join the share group: compete for the lock, subscribe to the channel.
39383
39391
  *
@@ -39421,16 +39429,57 @@ class ConnectionShareHub {
39421
39429
  setLeaderContextHandler(handler) {
39422
39430
  this.leaderContextHandler = handler;
39423
39431
  }
39432
+ /**
39433
+ * Rules-mode follower: hydrate local rules from the leader snapshot and
39434
+ * return flags evaluated for **this** document's context.
39435
+ *
39436
+ * @param handler Apply snapshot + local eval → flag map
39437
+ */
39438
+ setFollowerRulesApply(handler) {
39439
+ this.followerRulesApply = handler;
39440
+ }
39441
+ /**
39442
+ * Rules-mode follower: re-evaluate after local `updateContext` (no RPC).
39443
+ *
39444
+ * @param handler Context → flag map using this tab's rules store
39445
+ */
39446
+ setFollowerLocalEval(handler) {
39447
+ this.followerLocalEval = handler;
39448
+ }
39424
39449
  /**
39425
39450
  * Leader-only: publish the current evaluated flags to all followers.
39426
- * No-op on followers (avoids echo loops).
39451
+ * No-op on followers (avoids echo loops). Evaluated mode only.
39452
+ *
39453
+ * @param flags Evaluated flag map from the leader stream
39427
39454
  */
39428
39455
  broadcastFlags(flags) {
39456
+ if (this.mode !== "evaluated")
39457
+ return;
39429
39458
  if (this.role !== "leader" || !this.channel)
39430
39459
  return;
39431
39460
  this.latestFlags = flags;
39432
39461
  this.post({ type: "flags", memberId: this.memberId, flags });
39433
39462
  }
39463
+ /**
39464
+ * Leader-only (rules mode): publish a verified rules snapshot.
39465
+ * Followers hydrate and evaluate with their own context.
39466
+ *
39467
+ * @param snapshot Rules store snapshot (MAC already verified on the leader)
39468
+ * @param analytics Optional per-flag analytics map from rules
39469
+ */
39470
+ broadcastRules(snapshot, analytics) {
39471
+ if (this.mode !== "rules")
39472
+ return;
39473
+ if (this.role !== "leader" || !this.channel)
39474
+ return;
39475
+ this.latestRules = { snapshot, analytics };
39476
+ this.post({
39477
+ type: "rules",
39478
+ memberId: this.memberId,
39479
+ snapshot,
39480
+ analytics
39481
+ });
39482
+ }
39434
39483
  /** Leader-only: mirror a transport `onError` (auth, quota) to followers. */
39435
39484
  broadcastError(error2) {
39436
39485
  if (this.role !== "leader" || !this.channel)
@@ -39444,11 +39493,34 @@ class ConnectionShareHub {
39444
39493
  });
39445
39494
  }
39446
39495
  /**
39447
- * Transport used by follower FlagClients. `init` waits for the leader
39448
- * snapshot; `fetchFlags` RPCs `updateContext` through the leader.
39496
+ * Transport used by follower FlagClients.
39497
+ * - Evaluated: `init` waits for flags; `fetchFlags` RPCs through the leader.
39498
+ * - Rules: `init` waits for a rules snapshot; `fetchFlags` evaluates locally.
39449
39499
  * `destroy` is a no-op — {@link ConnectionShareHub.destroy} owns teardown.
39450
39500
  */
39451
39501
  createFollowerTransport() {
39502
+ if (this.mode === "rules") {
39503
+ return {
39504
+ init: async () => {
39505
+ const payload = await this.waitForRules();
39506
+ const flags = this.followerRulesApply?.(payload) ?? {};
39507
+ this.followerFlagsCallback?.(flags);
39508
+ },
39509
+ fetchFlags: async (context) => {
39510
+ if (!this.followerLocalEval) {
39511
+ throw new Error("Rules-share follower has no local eval handler.");
39512
+ }
39513
+ return this.followerLocalEval(context);
39514
+ },
39515
+ destroy: () => void 0,
39516
+ onFlagsUpdated: (callback) => {
39517
+ this.followerFlagsCallback = callback;
39518
+ },
39519
+ onError: (callback) => {
39520
+ this.followerErrorCallback = callback;
39521
+ }
39522
+ };
39523
+ }
39452
39524
  return {
39453
39525
  init: async () => {
39454
39526
  const flags = await this.waitForFlags();
@@ -39491,10 +39563,15 @@ class ConnectionShareHub {
39491
39563
  setTimeout(() => channel.close(), 0);
39492
39564
  }
39493
39565
  this.flagsWaiters = [];
39566
+ this.rulesWaiters = [];
39494
39567
  this.flagsTimeouts.splice(0).forEach(({ timeoutId, reject }) => {
39495
39568
  clearTimeout(timeoutId);
39496
39569
  reject(new Error("Connection share hub closed."));
39497
39570
  });
39571
+ this.rulesTimeouts.splice(0).forEach(({ timeoutId, reject }) => {
39572
+ clearTimeout(timeoutId);
39573
+ reject(new Error("Connection share hub closed."));
39574
+ });
39498
39575
  this.contextWaiters.forEach(({ reject }) => reject(new Error("Connection share hub closed.")));
39499
39576
  this.contextWaiters.clear();
39500
39577
  }
@@ -39591,7 +39668,16 @@ class ConnectionShareHub {
39591
39668
  return;
39592
39669
  switch (message.type) {
39593
39670
  case "hello":
39594
- if (this.role === "leader" && this.latestFlags) {
39671
+ if (this.role !== "leader")
39672
+ break;
39673
+ if (this.mode === "rules" && this.latestRules) {
39674
+ this.post({
39675
+ type: "rules",
39676
+ memberId: this.memberId,
39677
+ snapshot: this.latestRules.snapshot,
39678
+ analytics: this.latestRules.analytics
39679
+ });
39680
+ } else if (this.mode === "evaluated" && this.latestFlags) {
39595
39681
  this.post({
39596
39682
  type: "flags",
39597
39683
  memberId: this.memberId,
@@ -39600,7 +39686,7 @@ class ConnectionShareHub {
39600
39686
  }
39601
39687
  break;
39602
39688
  case "flags":
39603
- if (this.role === "leader")
39689
+ if (this.mode !== "evaluated" || this.role === "leader")
39604
39690
  return;
39605
39691
  if (!message.flags || typeof message.flags !== "object" || Array.isArray(message.flags)) {
39606
39692
  return;
@@ -39609,6 +39695,22 @@ class ConnectionShareHub {
39609
39695
  this.flagsWaiters.splice(0).forEach((resolve) => resolve(message.flags));
39610
39696
  this.followerFlagsCallback?.(message.flags);
39611
39697
  break;
39698
+ case "rules":
39699
+ if (this.mode !== "rules" || this.role === "leader")
39700
+ return;
39701
+ if (!message.snapshot || typeof message.snapshot !== "object")
39702
+ return;
39703
+ {
39704
+ const payload = {
39705
+ snapshot: message.snapshot,
39706
+ analytics: message.analytics
39707
+ };
39708
+ this.latestRules = payload;
39709
+ this.rulesWaiters.splice(0).forEach((resolve) => resolve(payload));
39710
+ const flags = this.followerRulesApply?.(payload) ?? {};
39711
+ this.followerFlagsCallback?.(flags);
39712
+ }
39713
+ break;
39612
39714
  case "error":
39613
39715
  if (this.role === "leader")
39614
39716
  return;
@@ -39621,7 +39723,7 @@ class ConnectionShareHub {
39621
39723
  }
39622
39724
  break;
39623
39725
  case "context-request":
39624
- if (this.role !== "leader")
39726
+ if (this.mode !== "evaluated" || this.role !== "leader")
39625
39727
  return;
39626
39728
  void this.handleContextRequest(message);
39627
39729
  break;
@@ -39706,6 +39808,31 @@ class ConnectionShareHub {
39706
39808
  this.flagsTimeouts.push({ timeoutId, reject });
39707
39809
  });
39708
39810
  }
39811
+ /**
39812
+ * Block until the leader has sent a `rules` snapshot, or {@link FOLLOWER_WAIT_MS}.
39813
+ *
39814
+ * @returns Latest rules payload from the leader
39815
+ */
39816
+ waitForRules() {
39817
+ if (this.latestRules)
39818
+ return Promise.resolve(this.latestRules);
39819
+ if (this.closed)
39820
+ return Promise.reject(new Error("Connection share hub closed."));
39821
+ return new Promise((resolve, reject) => {
39822
+ const timeoutId = setTimeout(() => {
39823
+ this.rulesWaiters = this.rulesWaiters.filter((waiter) => waiter !== onRules);
39824
+ this.rulesTimeouts = this.rulesTimeouts.filter((entry) => entry.timeoutId !== timeoutId);
39825
+ reject(new Error("Timed out waiting for shared rules snapshot from the leader."));
39826
+ }, FOLLOWER_WAIT_MS);
39827
+ const onRules = (payload) => {
39828
+ clearTimeout(timeoutId);
39829
+ this.rulesTimeouts = this.rulesTimeouts.filter((entry) => entry.timeoutId !== timeoutId);
39830
+ resolve(payload);
39831
+ };
39832
+ this.rulesWaiters.push(onRules);
39833
+ this.rulesTimeouts.push({ timeoutId, reject });
39834
+ });
39835
+ }
39709
39836
  /**
39710
39837
  * Follower `fetchFlags`: ask the leader to POST `/context` and wait for
39711
39838
  * `context-result`. Leaders run the handler locally.
@@ -41905,22 +42032,80 @@ function payloadHasExplicitPiiFields(payload) {
41905
42032
  return false;
41906
42033
  return payload.piiSalt !== void 0 || payload.piiAttributes !== void 0 || payload.piiValueEncoding !== void 0;
41907
42034
  }
42035
+ function piiAttributeKeysFromPayload(payload) {
42036
+ if (!payload || !Array.isArray(payload.piiAttributes))
42037
+ return [];
42038
+ return payload.piiAttributes.filter((k) => typeof k === "string" && k.length > 0);
42039
+ }
42040
+ function hasMalformedPiiAttributes(payload) {
42041
+ if (!payload || payload.piiAttributes === void 0)
42042
+ return false;
42043
+ if (!Array.isArray(payload.piiAttributes))
42044
+ return true;
42045
+ return payload.piiAttributes.some(
42046
+ (entry) => typeof entry !== "string" || entry.length === 0
42047
+ );
42048
+ }
42049
+ function hasExplicitInvalidPiiEncoding(payload) {
42050
+ if (!payload || payload.piiValueEncoding === void 0)
42051
+ return false;
42052
+ return payload.piiValueEncoding !== PII_VALUE_ENCODING;
42053
+ }
42054
+ function payloadRequiresUsablePiiWire(payload) {
42055
+ if (!payload)
42056
+ return false;
42057
+ if (hasExplicitInvalidPiiEncoding(payload))
42058
+ return true;
42059
+ if (hasMalformedPiiAttributes(payload))
42060
+ return true;
42061
+ if (piiAttributeKeysFromPayload(payload).length > 0)
42062
+ return true;
42063
+ return false;
42064
+ }
42065
+ function isExplicitPiiRequirementCleared(payload) {
42066
+ if (!payload || payload.piiAttributes === void 0)
42067
+ return false;
42068
+ if (!Array.isArray(payload.piiAttributes))
42069
+ return false;
42070
+ if (payload.piiAttributes.length !== 0)
42071
+ return false;
42072
+ if (hasExplicitInvalidPiiEncoding(payload))
42073
+ return false;
42074
+ return true;
42075
+ }
41908
42076
  function piiWireOptionsFromPayload(payload) {
41909
42077
  if (!payload)
41910
42078
  return null;
42079
+ if (hasExplicitInvalidPiiEncoding(payload))
42080
+ return null;
42081
+ if (hasMalformedPiiAttributes(payload))
42082
+ return null;
41911
42083
  const salt = typeof payload.piiSalt === "string" ? payload.piiSalt : "";
41912
42084
  if (!salt)
41913
42085
  return null;
41914
- const encoding = typeof payload.piiValueEncoding === "string" ? payload.piiValueEncoding : PII_VALUE_ENCODING;
41915
- if (encoding !== PII_VALUE_ENCODING)
41916
- return null;
41917
- const attrs = Array.isArray(payload.piiAttributes) ? payload.piiAttributes.filter((k) => typeof k === "string") : [];
42086
+ const attrs = piiAttributeKeysFromPayload(payload);
41918
42087
  return {
41919
42088
  piiAttributes: attrs,
41920
42089
  piiSalt: salt,
41921
42090
  piiValueEncoding: PII_VALUE_ENCODING
41922
42091
  };
41923
42092
  }
42093
+ function isPiiWireBroken(payload, resolved, previousPriorHadPiiAttrs = false) {
42094
+ if (hasExplicitInvalidPiiEncoding(payload) || hasMalformedPiiAttributes(payload)) {
42095
+ return true;
42096
+ }
42097
+ if (resolved) {
42098
+ return false;
42099
+ }
42100
+ if (isExplicitPiiRequirementCleared(payload))
42101
+ return false;
42102
+ if (payloadRequiresUsablePiiWire(payload))
42103
+ return true;
42104
+ if (previousPriorHadPiiAttrs && payloadHasExplicitPiiFields(payload)) {
42105
+ return true;
42106
+ }
42107
+ return false;
42108
+ }
41924
42109
  function resolvePiiWireForDelta(previous, action) {
41925
42110
  if (!payloadHasExplicitPiiFields(action)) {
41926
42111
  return previous;
@@ -41973,7 +42158,8 @@ function createEmptyRulesState() {
41973
42158
  segments: /* @__PURE__ */ new Map(),
41974
42159
  ready: false,
41975
42160
  needsFullConfig: true,
41976
- piiWire: null
42161
+ piiWire: null,
42162
+ piiWireBroken: false
41977
42163
  };
41978
42164
  }
41979
42165
  function cloneState(state) {
@@ -41988,7 +42174,8 @@ function cloneState(state) {
41988
42174
  piiAttributes: [...state.piiWire.piiAttributes],
41989
42175
  piiSalt: state.piiWire.piiSalt,
41990
42176
  piiValueEncoding: state.piiWire.piiValueEncoding
41991
- } : null
42177
+ } : null,
42178
+ piiWireBroken: state.piiWireBroken
41992
42179
  };
41993
42180
  }
41994
42181
  function segmentsFromRecord(record) {
@@ -42015,14 +42202,18 @@ function hydrateFromFull(action) {
42015
42202
  if (flag?.key)
42016
42203
  flags.set(flag.key, flag);
42017
42204
  }
42205
+ const piiWire = piiWireOptionsFromPayload(action);
42206
+ const piiWireBroken = isPiiWireBroken(action, piiWire, false);
42018
42207
  return {
42019
42208
  version: action.version,
42020
42209
  expiresAt: action.expiresAt,
42021
42210
  flags,
42022
42211
  segments: segmentsFromRecord(action.segments),
42023
- ready: true,
42024
- needsFullConfig: false,
42025
- piiWire: piiWireOptionsFromPayload(action)
42212
+ // Usable rules, but PII metadata must be valid when hashing is required.
42213
+ ready: !piiWireBroken,
42214
+ needsFullConfig: piiWireBroken,
42215
+ piiWire,
42216
+ piiWireBroken
42026
42217
  };
42027
42218
  }
42028
42219
  function applyPatch(state, action) {
@@ -42035,14 +42226,19 @@ function applyPatch(state, action) {
42035
42226
  flags.set(flag.key, flag);
42036
42227
  }
42037
42228
  const nextPii = resolvePiiWireForDelta(state.piiWire, action);
42229
+ const priorHadPiiAttrs = (state.piiWire?.piiAttributes.length ?? 0) > 0 || state.piiWireBroken;
42230
+ const computedBroken = isPiiWireBroken(action, nextPii, priorHadPiiAttrs);
42231
+ const recoveredWithRequiredAttrs = !!nextPii && nextPii.piiAttributes.length > 0;
42232
+ const piiWireBroken = isExplicitPiiRequirementCleared(action) ? false : recoveredWithRequiredAttrs ? false : computedBroken || state.piiWireBroken;
42038
42233
  return {
42039
42234
  version: action.toVersion,
42040
42235
  expiresAt: typeof action.expiresAt === "number" ? action.expiresAt : state.expiresAt,
42041
42236
  flags,
42042
42237
  segments: applySegmentUpserts(state.segments, action.segments),
42043
- ready: true,
42044
- needsFullConfig: false,
42045
- piiWire: nextPii
42238
+ ready: !piiWireBroken,
42239
+ needsFullConfig: piiWireBroken,
42240
+ piiWire: nextPii,
42241
+ piiWireBroken
42046
42242
  };
42047
42243
  }
42048
42244
  function reduceRules(state, action, now = Date.now()) {
@@ -42062,8 +42258,8 @@ function reduceRules(state, action, now = Date.now()) {
42062
42258
  ...cloneState(state),
42063
42259
  version: state.version,
42064
42260
  expiresAt: action.expiresAt,
42065
- ready: true,
42066
- needsFullConfig: state.flags.size === 0 || versionMismatch ? true : state.needsFullConfig
42261
+ ready: !state.piiWireBroken,
42262
+ needsFullConfig: state.piiWireBroken || state.flags.size === 0 || versionMismatch ? true : state.needsFullConfig
42067
42263
  }
42068
42264
  };
42069
42265
  }
@@ -42119,8 +42315,8 @@ function reduceRules(state, action, now = Date.now()) {
42119
42315
  ...current,
42120
42316
  version: action.toVersion,
42121
42317
  expiresAt: typeof action.expiresAt === "number" ? action.expiresAt : current.expiresAt,
42122
- ready: true,
42123
- needsFullConfig: false
42318
+ ready: !current.piiWireBroken,
42319
+ needsFullConfig: current.piiWireBroken
42124
42320
  }
42125
42321
  };
42126
42322
  }
@@ -42143,9 +42339,12 @@ class RulesStore {
42143
42339
  return this.state;
42144
42340
  }
42145
42341
  /**
42146
- * Fail-closed readiness: lease not expired and store marked ready.
42342
+ * Fail-closed readiness: lease not expired, store marked ready, and PII
42343
+ * wire metadata usable when hashing is required.
42147
42344
  */
42148
42345
  isReady(now = Date.now()) {
42346
+ if (this.state.piiWireBroken)
42347
+ return false;
42149
42348
  if (!this.state.ready)
42150
42349
  return false;
42151
42350
  if (!this.state.expiresAt)
@@ -42229,7 +42428,9 @@ class RulesStore {
42229
42428
  piiAttributes: [...pii.piiAttributes],
42230
42429
  piiSalt: pii.piiSalt,
42231
42430
  piiValueEncoding: pii.piiValueEncoding
42232
- } : {}
42431
+ } : {},
42432
+ // Persist fail-closed even when piiWire is null (unusable salt/encoding).
42433
+ ...this.state.piiWireBroken ? { piiWireBroken: true } : {}
42233
42434
  };
42234
42435
  }
42235
42436
  hydrateFromSnapshot(snapshot, now = Date.now()) {
@@ -42239,14 +42440,17 @@ class RulesStore {
42239
42440
  flags.set(flag.key, flag);
42240
42441
  }
42241
42442
  const expired = isConfigPayloadExpired(snapshot.expiresAt, now);
42443
+ const piiWire = piiWireOptionsFromPayload(snapshot);
42444
+ const piiWireBroken = snapshot.piiWireBroken === true || isPiiWireBroken(snapshot, piiWire, false);
42242
42445
  this.state = {
42243
42446
  version: snapshot.version,
42244
42447
  expiresAt: snapshot.expiresAt,
42245
42448
  flags,
42246
42449
  segments: segmentsFromRecord(snapshot.segments),
42247
- ready: !expired && flags.size > 0,
42248
- needsFullConfig: expired || flags.size === 0,
42249
- piiWire: piiWireOptionsFromPayload(snapshot)
42450
+ ready: !expired && flags.size > 0 && !piiWireBroken,
42451
+ needsFullConfig: expired || flags.size === 0 || piiWireBroken,
42452
+ piiWire,
42453
+ piiWireBroken
42250
42454
  };
42251
42455
  }
42252
42456
  }
@@ -43166,11 +43370,11 @@ class FlagClient {
43166
43370
  this.env = options.env;
43167
43371
  this.enableFlagmint = options.enableFlagmint ?? true;
43168
43372
  this.configSync = options.configSync ?? false;
43169
- this.shareConnection = options.shareConnection ?? (this.configSync ? false : isConnectionSharingAvailable());
43373
+ logger.setup({ debugLog: options.debugLog });
43374
+ this.shareConnection = options.shareConnection ?? isConnectionSharingAvailable();
43170
43375
  if (this.configSync) {
43171
43376
  this.rulesStore = new RulesStore();
43172
43377
  }
43173
- logger.setup({ debugLog: options.debugLog });
43174
43378
  if (this.previewMode && this.rawFlags && Object.keys(this.rawFlags).length > 0) {
43175
43379
  this.flags = this.evaluateLocally(this.rawFlags, this.context);
43176
43380
  this.readyPromise = Promise.resolve();
@@ -43234,7 +43438,7 @@ class FlagClient {
43234
43438
  return;
43235
43439
  }
43236
43440
  await this.setupTransport(options);
43237
- this.shareHub?.broadcastFlags(this.flags);
43441
+ this.broadcastShareSnapshot();
43238
43442
  this.isInitialized = true;
43239
43443
  this.resolveReady();
43240
43444
  } catch (err) {
@@ -43274,19 +43478,26 @@ class FlagClient {
43274
43478
  * @returns true when this document is a follower and should skip opening a stream.
43275
43479
  */
43276
43480
  async joinConnectionShare(options) {
43277
- if (!this.shareConnection || options.transport) {
43481
+ if (!this.shareConnection) {
43482
+ return false;
43483
+ }
43484
+ if (options.transport && !options.share) {
43278
43485
  return false;
43279
43486
  }
43280
43487
  if (!options.share && !isConnectionSharingAvailable()) {
43281
43488
  return false;
43282
43489
  }
43283
- this.shareHub = new ConnectionShareHub(this.apiKey, options.share);
43490
+ const shareMode = this.configSync ? "rules" : "evaluated";
43491
+ this.shareHub = new ConnectionShareHub(this.apiKey, {
43492
+ ...options.share,
43493
+ mode: shareMode
43494
+ });
43284
43495
  this.shareHub.onPromote(async () => {
43285
43496
  logger.log("[FlagClient] Share leader departed. Promoting this document to hold the stream.");
43286
43497
  try {
43287
43498
  await this.setupTransport(options);
43288
43499
  this.attachShareLeader();
43289
- this.shareHub?.broadcastFlags(this.flags);
43500
+ this.broadcastShareSnapshot();
43290
43501
  } catch (err) {
43291
43502
  const error2 = err instanceof Error ? err : new Error(String(err));
43292
43503
  logger.error("[FlagClient] Promotion to share leader failed:", error2);
@@ -43295,7 +43506,12 @@ class FlagClient {
43295
43506
  });
43296
43507
  const role = await this.shareHub.join();
43297
43508
  if (role === "follower") {
43298
- logger.log("[FlagClient] Following an existing Flagmint stream in another same-origin document.");
43509
+ logger.log(
43510
+ shareMode === "rules" ? "[FlagClient] Following shared config-sync rules from another same-origin document." : "[FlagClient] Following an existing Flagmint stream in another same-origin document."
43511
+ );
43512
+ if (shareMode === "rules") {
43513
+ this.attachRulesShareFollower();
43514
+ }
43299
43515
  this.transport = this.shareHub.createFollowerTransport();
43300
43516
  this.bindTransport(this.transport);
43301
43517
  await this.transport.init();
@@ -43304,8 +43520,15 @@ class FlagClient {
43304
43520
  this.attachShareLeader();
43305
43521
  return false;
43306
43522
  }
43523
+ /**
43524
+ * Leader: wire context RPC for evaluated share only.
43525
+ * Rules share never forwards follower context onto the stream.
43526
+ */
43307
43527
  attachShareLeader() {
43308
- this.shareHub?.setLeaderContextHandler(async (context, options) => {
43528
+ if (!this.shareHub || this.shareHub.shareMode === "rules") {
43529
+ return;
43530
+ }
43531
+ this.shareHub.setLeaderContextHandler(async (context, options) => {
43309
43532
  const mergedContext = toJsonCloneable({ ...this.context, ...context });
43310
43533
  if (options?.persist === true) {
43311
43534
  this.context = mergedContext;
@@ -43314,9 +43537,6 @@ class FlagClient {
43314
43537
  }
43315
43538
  }
43316
43539
  if (!this.transport || typeof this.transport.fetchFlags !== "function") {
43317
- if (this.configSync && this.rulesStore) {
43318
- return this.evaluateFromRulesStore(mergedContext);
43319
- }
43320
43540
  return this.flags;
43321
43541
  }
43322
43542
  return this.transport.fetchFlags(mergedContext, {
@@ -43324,6 +43544,41 @@ class FlagClient {
43324
43544
  });
43325
43545
  });
43326
43546
  }
43547
+ /**
43548
+ * Follower hooks for rules-only share: hydrate rules locally and evaluate
43549
+ * with this document's context (never the leader's).
43550
+ */
43551
+ attachRulesShareFollower() {
43552
+ if (!this.shareHub || !this.rulesStore)
43553
+ return;
43554
+ this.shareHub.setFollowerRulesApply((payload) => {
43555
+ this.rulesStore.hydrateFromSnapshot(payload.snapshot);
43556
+ if (payload.analytics) {
43557
+ this.updateAnalytics(payload.analytics);
43558
+ }
43559
+ const flags = this.evaluateFromRulesStore();
43560
+ this.updateFlags(flags, true);
43561
+ return flags;
43562
+ });
43563
+ this.shareHub.setFollowerLocalEval((context) => {
43564
+ return this.evaluateFromRulesStore(context);
43565
+ });
43566
+ }
43567
+ /**
43568
+ * Leader: push the right snapshot type to followers after flags/rules change.
43569
+ */
43570
+ broadcastShareSnapshot() {
43571
+ if (!this.shareHub || this.shareHub.currentRole !== "leader")
43572
+ return;
43573
+ if (this.shareHub.shareMode === "rules" && this.rulesStore) {
43574
+ const snapshot = this.rulesStore.toSnapshot();
43575
+ if (snapshot.flags.length === 0)
43576
+ return;
43577
+ this.shareHub.broadcastRules(snapshot, this.analyticsMapFromRulesStore());
43578
+ return;
43579
+ }
43580
+ this.shareHub.broadcastFlags(this.flags);
43581
+ }
43327
43582
  /**
43328
43583
  * Creates and initializes the transport layer used to receive feature flags
43329
43584
  * updates from the Flagmint platform.
@@ -43468,7 +43723,7 @@ class FlagClient {
43468
43723
  }
43469
43724
  this.notifySubscribers();
43470
43725
  if (!fromShare) {
43471
- this.shareHub?.broadcastFlags(newFlags);
43726
+ this.broadcastShareSnapshot();
43472
43727
  }
43473
43728
  }
43474
43729
  /**