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 +20 -6
- package/dist/flagmint.cjs.js +297 -42
- package/dist/flagmint.cjs.js.map +1 -1
- package/dist/flagmint.es.js +297 -42
- package/dist/flagmint.es.js.map +1 -1
- package/dist/flagmint.umd.js +297 -42
- package/dist/flagmint.umd.js.map +1 -1
- package/dist/sdk/core/client.d.ts +17 -1
- package/dist/sdk/core/config-sync/index.d.ts +1 -1
- package/dist/sdk/core/config-sync/piiHash.d.ts +46 -0
- package/dist/sdk/core/config-sync/rulesStore.d.ts +7 -1
- package/dist/sdk/core/config-sync/types.d.ts +5 -0
- package/dist/sdk/core/helpers/connectionShare.d.ts +73 -22
- package/package.json +1 -1
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
|
|
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**
|
|
169
|
-
-
|
|
170
|
-
-
|
|
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
|
|
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
|
package/dist/flagmint.cjs.js
CHANGED
|
@@ -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.
|
|
39369
|
-
this.
|
|
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.
|
|
39448
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
42024
|
-
|
|
42025
|
-
|
|
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:
|
|
42044
|
-
needsFullConfig:
|
|
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:
|
|
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:
|
|
42123
|
-
needsFullConfig:
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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.
|
|
43726
|
+
this.broadcastShareSnapshot();
|
|
43472
43727
|
}
|
|
43473
43728
|
}
|
|
43474
43729
|
/**
|