@nebulr-group/bridge-svelte 0.4.2 → 0.4.4

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.
@@ -1,2 +1,12 @@
1
1
  export type { FlagRequirement, NavigationDecision, RouteGuard, RouteGuardConfig, RouteRule } from '@nebulr-group/bridge-auth-core';
2
2
  export declare function createRouteGuard(flagsReady?: Promise<void>): import("@nebulr-group/bridge-auth-core").RouteGuard;
3
+ /**
4
+ * Does any route rule's `featureFlag` requirement mention this key?
5
+ * (TBP-575.)
6
+ *
7
+ * The live re-check runs on every flag mutation the app receives, and most of
8
+ * them have nothing to do with routing. Without this filter, flipping any flag
9
+ * in a busy app would cost a `bulkEvaluate` round-trip on every connected
10
+ * client — so this is a cost guard, not a correctness one.
11
+ */
12
+ export declare function routeRulesReferenceFlag(key: string): boolean;
@@ -26,3 +26,27 @@ export function createRouteGuard(flagsReady) {
26
26
  }
27
27
  };
28
28
  }
29
+ /**
30
+ * Does any route rule's `featureFlag` requirement mention this key?
31
+ * (TBP-575.)
32
+ *
33
+ * The live re-check runs on every flag mutation the app receives, and most of
34
+ * them have nothing to do with routing. Without this filter, flipping any flag
35
+ * in a busy app would cost a `bulkEvaluate` round-trip on every connected
36
+ * client — so this is a cost guard, not a correctness one.
37
+ */
38
+ export function routeRulesReferenceFlag(key) {
39
+ const rules = getRouteGuardConfig()?.rules ?? [];
40
+ return rules.some((rule) => {
41
+ const req = rule.featureFlag;
42
+ if (!req)
43
+ return false;
44
+ if (typeof req === 'string')
45
+ return req === key;
46
+ if ('any' in req)
47
+ return req.any.includes(key);
48
+ if ('all' in req)
49
+ return req.all.includes(key);
50
+ return false;
51
+ });
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ // TBP-575 — the filter that decides whether a flag change is worth re-checking
2
+ // the current route for.
3
+ //
4
+ // Getting this wrong is silent in both directions: too narrow and the live
5
+ // re-check never fires (the feature looks broken), too wide and every flag
6
+ // flip costs every connected client a bulkEvaluate round-trip.
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ let _rules = [];
9
+ vi.mock('../client/stores/config.store.js', () => ({
10
+ getRouteGuardConfig: () => ({ rules: _rules }),
11
+ }));
12
+ vi.mock('../core/bridge-instance.js', () => ({
13
+ getBridgeAuth: () => ({ createRouteGuard: () => ({}) }),
14
+ }));
15
+ const { routeRulesReferenceFlag } = await import('./route-guard.js');
16
+ describe('routeRulesReferenceFlag', () => {
17
+ it('matches a plain string requirement', () => {
18
+ _rules = [{ match: '/lab', featureFlag: 'holo-experimental' }];
19
+ expect(routeRulesReferenceFlag('holo-experimental')).toBe(true);
20
+ expect(routeRulesReferenceFlag('something-else')).toBe(false);
21
+ });
22
+ it('matches inside an `any` requirement', () => {
23
+ _rules = [{ match: '/lab', featureFlag: { any: ['a', 'b'] } }];
24
+ expect(routeRulesReferenceFlag('b')).toBe(true);
25
+ expect(routeRulesReferenceFlag('c')).toBe(false);
26
+ });
27
+ it('matches inside an `all` requirement', () => {
28
+ _rules = [{ match: '/lab', featureFlag: { all: ['a', 'b'] } }];
29
+ expect(routeRulesReferenceFlag('a')).toBe(true);
30
+ expect(routeRulesReferenceFlag('c')).toBe(false);
31
+ });
32
+ it('ignores rules with no flag requirement', () => {
33
+ _rules = [{ match: '/public', public: true }, { match: '/billing', billing: 'hard' }];
34
+ expect(routeRulesReferenceFlag('anything')).toBe(false);
35
+ });
36
+ it('scans every rule, not just the first', () => {
37
+ _rules = [
38
+ { match: '/a', featureFlag: 'one' },
39
+ { match: '/b', public: true },
40
+ { match: '/c', featureFlag: 'two' },
41
+ ];
42
+ expect(routeRulesReferenceFlag('two')).toBe(true);
43
+ });
44
+ it('survives an empty or absent rule set', () => {
45
+ _rules = [];
46
+ expect(routeRulesReferenceFlag('x')).toBe(false);
47
+ });
48
+ });
@@ -2,7 +2,7 @@
2
2
  import { beforeNavigate, goto } from '$app/navigation';
3
3
  import { page } from '$app/stores';
4
4
  import { onMount, onDestroy } from 'svelte';
5
- import { createRouteGuard } from '../auth/route-guard.js';
5
+ import { createRouteGuard, routeRulesReferenceFlag } from '../auth/route-guard.js';
6
6
  import {
7
7
  getBridgeAuth,
8
8
  isAuthenticated,
@@ -14,6 +14,7 @@
14
14
  import { setBridgeContext } from '../core/use-bridge.js';
15
15
  import { getConfig } from './stores/config.store.js';
16
16
  import {
17
+ onBridgeFlagChange,
17
18
  startBridgeRuntime,
18
19
  stopBridgeRuntime,
19
20
  type StartBridgeRuntimeOptions,
@@ -91,6 +92,31 @@
91
92
  }
92
93
  }
93
94
 
95
+ // TBP-575 — re-evaluate the CURRENT route when a flag it depends on changes.
96
+ //
97
+ // Route rules are only evaluated on navigation. Without this, flipping a
98
+ // flag off never ejects the user sitting on the route it gates — they keep
99
+ // the page until they happen to navigate. That makes a route flag useless as
100
+ // a kill switch, which is most of the reason to put a flag on a route.
101
+ //
102
+ // Debounced because one admin action can emit several flag messages, and
103
+ // each re-check costs a bulkEvaluate round-trip.
104
+ let _recheckTimer: ReturnType<typeof setTimeout> | undefined;
105
+ let _stopFlagWatch: (() => void) | undefined;
106
+
107
+ function scheduleRouteRecheck() {
108
+ if (_recheckTimer) clearTimeout(_recheckTimer);
109
+ _recheckTimer = setTimeout(() => {
110
+ _recheckTimer = undefined;
111
+ // No `cancel` here: there is no navigation in flight to cancel. A denied
112
+ // verdict redirects the user off the page they are already on.
113
+ handleRoute(window.location.pathname).catch(() => {
114
+ /* a failed re-check must never break the page; the next navigation
115
+ re-evaluates anyway */
116
+ });
117
+ }, 150);
118
+ }
119
+
94
120
  // Stash a teardown for the dynamically-attached capabilities (today: flags).
95
121
  let _capabilityStop: (() => Promise<void>) | undefined;
96
122
 
@@ -99,6 +125,13 @@
99
125
  // attach, session.snapshot fanout, billing-family event dispatch.
100
126
  startBridgeRuntime(runtime);
101
127
 
128
+ // TBP-575 — subscribe AFTER the runtime exists so the hook is registered
129
+ // on the live client. Filtered to keys the route rules actually name; a
130
+ // flag nothing routes on must not cost every client a round-trip.
131
+ _stopFlagWatch = onBridgeFlagChange((change) => {
132
+ if (routeRulesReferenceFlag(change.key)) scheduleRouteRecheck();
133
+ });
134
+
102
135
  // Fetch app config outside load() so we use the correct fetch context.
103
136
  // LoginForm also calls ensureAppConfig() — both share the same in-flight promise.
104
137
  void ensureAppConfig();
@@ -130,6 +163,12 @@
130
163
  });
131
164
 
132
165
  onDestroy(() => {
166
+ if (_recheckTimer) {
167
+ clearTimeout(_recheckTimer);
168
+ _recheckTimer = undefined;
169
+ }
170
+ _stopFlagWatch?.();
171
+ _stopFlagWatch = undefined;
133
172
  void (async () => {
134
173
  if (_capabilityStop) {
135
174
  try { await _capabilityStop(); } catch { /* ignore */ }
@@ -41,7 +41,7 @@
41
41
  * instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
42
42
  * to flush the realtime client and unsubscribe from the token store.
43
43
  */
44
- import { RealtimeClient, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
44
+ import { RealtimeClient, type FlagChange, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
45
45
  /**
46
46
  * Advanced runtime overrides. Product consumers never pass these; tests,
47
47
  * Storybook harnesses, and the demo workspace use them to override the
@@ -96,6 +96,13 @@ export declare function getCurrentAuthToken(): string | undefined;
96
96
  export declare function onBridgeRealtimeOpen(handler: () => void): () => void;
97
97
  /** Subscribe to realtime `close` events. Returns an unsubscribe fn. */
98
98
  export declare function onBridgeRealtimeClose(handler: () => void): () => void;
99
+ /**
100
+ * Subscribe to realtime flag mutations (TBP-575). Returns an unsubscribe fn.
101
+ *
102
+ * The route-guard cache is already invalidated before subscribers run, so a
103
+ * handler that re-evaluates a route will read fresh values.
104
+ */
105
+ export declare function onBridgeFlagChange(handler: (change: FlagChange) => void): () => void;
99
106
  /** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
100
107
  export declare function onBridgeRealtimeSnapshot(handler: (msg: SessionSnapshotMessage) => void): () => void;
101
108
  /**
@@ -55,6 +55,9 @@ let _originalFetch;
55
55
  const _onOpenSubs = new Set();
56
56
  const _onCloseSubs = new Set();
57
57
  const _onSnapshotSubs = new Set();
58
+ // TBP-575 — realtime flag mutations, fanned out so the route guard can
59
+ // invalidate its (separate) cache and re-evaluate the current route.
60
+ const _onFlagChangeSubs = new Set();
58
61
  const _onUserStateSubs = new Set();
59
62
  /**
60
63
  * Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
@@ -146,6 +149,34 @@ export function startBridgeRuntime(options = {}) {
146
149
  catch { /* subscriber errors swallowed */ }
147
150
  }
148
151
  });
152
+ // TBP-575 — connected, handshaken, and subscribed to nothing. Distinct from
153
+ // 'closed': the socket is alive, so no reconnect is coming, but nothing will
154
+ // ever arrive on it. Surfacing this is the whole point — this state used to
155
+ // report as 'open'.
156
+ // Guarded: bridge-svelte and auth-core version independently, so a consumer
157
+ // can resolve an older auth-core that has no such hook. An unguarded call
158
+ // would crash bootstrap — a worse failure than the missing signal.
159
+ _realtime.setOnDegraded?.(() => {
160
+ _setRealtimeStatus('degraded');
161
+ });
162
+ // TBP-575 — a flag changed on the wire. Two caches need to hear about it and
163
+ // only one of them was ever told:
164
+ // - BridgeFlags (FF 2.0) — driven by realtime already, via attach()
165
+ // - FeatureFlagService — what ROUTE GUARDS read, 5-min TTL, deaf
166
+ // Invalidating here is what makes a route flag take effect on the next
167
+ // navigation instead of up to five minutes later.
168
+ _realtime.setOnFlagChange?.((change) => {
169
+ try {
170
+ getBridgeAuth().invalidateFeatureFlagCache();
171
+ }
172
+ catch { /* auth instance may not exist yet; next hydrate covers it */ }
173
+ for (const fn of _onFlagChangeSubs) {
174
+ try {
175
+ fn(change);
176
+ }
177
+ catch { /* subscriber errors swallowed */ }
178
+ }
179
+ });
149
180
  _realtime.setOnSnapshot((msg) => {
150
181
  try {
151
182
  applySessionSnapshot(msg.data);
@@ -300,6 +331,16 @@ export function onBridgeRealtimeClose(handler) {
300
331
  _onCloseSubs.add(handler);
301
332
  return () => _onCloseSubs.delete(handler);
302
333
  }
334
+ /**
335
+ * Subscribe to realtime flag mutations (TBP-575). Returns an unsubscribe fn.
336
+ *
337
+ * The route-guard cache is already invalidated before subscribers run, so a
338
+ * handler that re-evaluates a route will read fresh values.
339
+ */
340
+ export function onBridgeFlagChange(handler) {
341
+ _onFlagChangeSubs.add(handler);
342
+ return () => _onFlagChangeSubs.delete(handler);
343
+ }
303
344
  /** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
304
345
  export function onBridgeRealtimeSnapshot(handler) {
305
346
  _onSnapshotSubs.add(handler);
@@ -321,6 +362,7 @@ export function __resetBridgeRuntime() {
321
362
  _onOpenSubs.clear();
322
363
  _onCloseSubs.clear();
323
364
  _onSnapshotSubs.clear();
365
+ _onFlagChangeSubs.clear();
324
366
  _onUserStateSubs.clear();
325
367
  _currentAuthToken = undefined;
326
368
  if (_unsubscribeAuth) {
@@ -11,6 +11,9 @@ let _tokenStore;
11
11
  let _onOpen;
12
12
  let _onClose;
13
13
  let _onSnapshot;
14
+ let _onDegraded;
15
+ let _invalidateCalls = 0;
16
+ let _onFlagChange;
14
17
  let _onUserState;
15
18
  const _channelScopeCalls = [];
16
19
  const _reauthCalls = [];
@@ -20,6 +23,9 @@ let _capturedRealtimeConfig;
20
23
  // Reset the spy state between tests.
21
24
  function resetSpies() {
22
25
  _onOpen = _onClose = _onSnapshot = _onUserState = undefined;
26
+ _onDegraded = undefined;
27
+ _onFlagChange = undefined;
28
+ _invalidateCalls = 0;
23
29
  _channelScopeCalls.length = 0;
24
30
  _reauthCalls.length = 0;
25
31
  _startCalls = 0;
@@ -34,6 +40,7 @@ vi.mock('./bridge-instance.js', () => ({
34
40
  getBridgeAuth: () => ({
35
41
  getApiContext: () => ({ appId: 'app-1', accessToken: null }),
36
42
  refreshTokens: async () => { },
43
+ invalidateFeatureFlagCache: () => { _invalidateCalls += 1; },
37
44
  }),
38
45
  }));
39
46
  vi.mock('../client/stores/config.store.js', () => ({
@@ -53,6 +60,8 @@ vi.mock('@nebulr-group/bridge-auth-core', () => {
53
60
  setOnOpen(fn) { _onOpen = fn; }
54
61
  setOnClose(fn) { _onClose = fn; }
55
62
  setOnSnapshot(fn) { _onSnapshot = fn; }
63
+ setOnDegraded(fn) { _onDegraded = fn; }
64
+ setOnFlagChange(fn) { _onFlagChange = fn; }
56
65
  setOnUserState(fn) { _onUserState = fn; }
57
66
  setAppId(v) { _channelScopeCalls.push({ method: 'setAppId', value: v }); }
58
67
  setWorkspaceId(v) { _channelScopeCalls.push({ method: 'setWorkspaceId', value: v }); }
@@ -80,7 +89,7 @@ afterEach(async () => {
80
89
  __resetBridgeRuntime();
81
90
  });
82
91
  // ── Imports under test ─────────────────────────────────────────────────────
83
- import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, } from './bridge-runtime.js';
92
+ import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, onBridgeFlagChange, } from './bridge-runtime.js';
84
93
  import { realtimeStatus } from './realtime-status.js';
85
94
  // ── Helpers ────────────────────────────────────────────────────────────────
86
95
  function makeJwt(claims) {
@@ -201,3 +210,59 @@ describe('stopBridgeRuntime', () => {
201
210
  await expect(stopBridgeRuntime()).resolves.not.toThrow();
202
211
  });
203
212
  });
213
+ // ─────────────────────────────────────────────────────────────────────────────
214
+ // TBP-575 — route flags were never push-updated.
215
+ //
216
+ // Route guards read FeatureFlagService (a 5-minute TTL cache fed by
217
+ // bulkEvaluate). `<FeatureFlag>` reads BridgeFlags. Realtime only ever wrote to
218
+ // the second one, so a flag flip took up to five minutes to affect a route —
219
+ // not because the TTL was wrong, but because nothing told that cache anything
220
+ // had changed.
221
+ // ─────────────────────────────────────────────────────────────────────────────
222
+ describe('realtime flag changes reach the route-guard cache (TBP-575)', () => {
223
+ it('invalidates the route-guard flag cache on a flag mutation', () => {
224
+ startBridgeRuntime();
225
+ expect(_invalidateCalls).toBe(0);
226
+ _onFlagChange?.({ key: 'holo-experimental', kind: 'updated' });
227
+ expect(_invalidateCalls).toBe(1);
228
+ });
229
+ it('invalidates on removal too — a deleted flag changes route verdicts', () => {
230
+ startBridgeRuntime();
231
+ _onFlagChange?.({ key: 'holo-experimental', kind: 'removed' });
232
+ expect(_invalidateCalls).toBe(1);
233
+ });
234
+ it('fans the change out to subscribers so they can re-evaluate the route', () => {
235
+ startBridgeRuntime();
236
+ const seen = [];
237
+ const off = onBridgeFlagChange((c) => seen.push(c));
238
+ _onFlagChange?.({ key: 'holo-experimental', kind: 'updated' });
239
+ expect(seen).toEqual([{ key: 'holo-experimental', kind: 'updated' }]);
240
+ off();
241
+ _onFlagChange?.({ key: 'other', kind: 'updated' });
242
+ expect(seen).toHaveLength(1);
243
+ });
244
+ it('invalidates BEFORE notifying subscribers, so a re-check reads fresh values', () => {
245
+ startBridgeRuntime();
246
+ let invalidatedWhenNotified = -1;
247
+ onBridgeFlagChange(() => { invalidatedWhenNotified = _invalidateCalls; });
248
+ _onFlagChange?.({ key: 'x', kind: 'updated' });
249
+ expect(invalidatedWhenNotified).toBe(1);
250
+ });
251
+ it('a throwing subscriber does not stop the others', () => {
252
+ startBridgeRuntime();
253
+ let reached = false;
254
+ onBridgeFlagChange(() => { throw new Error('boom'); });
255
+ onBridgeFlagChange(() => { reached = true; });
256
+ _onFlagChange?.({ key: 'x', kind: 'updated' });
257
+ expect(reached).toBe(true);
258
+ });
259
+ });
260
+ describe('degraded realtime is reported as degraded, not open (TBP-575)', () => {
261
+ it('mirrors the degraded state into realtimeStatus', () => {
262
+ startBridgeRuntime();
263
+ _onDegraded?.();
264
+ // A socket that is connected but subscribed to nothing used to report
265
+ // 'open' — which is exactly how a dead transport passed for healthy.
266
+ expect(get(realtimeStatus)).toBe('degraded');
267
+ });
268
+ });
package/dist/index.d.ts CHANGED
@@ -41,6 +41,7 @@ export { sha256Email } from './client/tracking/pii-hashing.js';
41
41
  export type { AuthConfigResponse, AuthResult, AuthState, BridgeAuthConfig, BridgeAuthEventName, BridgeAuthEvents, FederationConnection, MagicLinkResult, MfaResult, PasskeyAuthOptions, PasskeyRegistrationOptions, PasskeyVerificationResult, SignupResult, SsoOptions, SsoResult, TenantUser, } from '@nebulr-group/bridge-auth-core';
42
42
  export { BridgeAuth, BridgeAuthError, HttpError, TeamService, ApiTokenService } from '@nebulr-group/bridge-auth-core';
43
43
  export type { SessionStalePayload } from '@nebulr-group/bridge-auth-core';
44
+ export type { QuotaSnapshot } from '@nebulr-group/bridge-auth-core';
44
45
  export type { ApiToken, CreateApiTokenInput, CreateApiTokenResponse, } from '@nebulr-group/bridge-auth-core';
45
46
  export type { TeamProfile, TeamProfileUpdateInput, TeamUser, TeamUserListResult, TeamUserUpdateInput, TeamWorkspace, TeamWorkspaceUpdateInput, } from '@nebulr-group/bridge-auth-core';
46
47
  export type { Plan, PriceOfferSdk, SubscriptionStatus, CheckoutSession, Workspace, } from '@nebulr-group/bridge-auth-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
5
5
  "author": "Iman Pouya",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@simplewebauthn/browser": "^13.0.0",
67
- "@nebulr-group/bridge-auth-core": "0.4.1"
67
+ "@nebulr-group/bridge-auth-core": "0.4.3"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@sveltejs/kit": "^2.16.0",