@nebulr-group/bridge-svelte 0.3.2 → 0.4.0-beta.0

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.
Files changed (85) hide show
  1. package/dist/client/BridgeBootstrap.d.ts +1 -1
  2. package/dist/client/BridgeBootstrap.js +104 -27
  3. package/dist/client/BridgeBootstrap.svelte +65 -4
  4. package/dist/client/BridgeBootstrap.svelte.d.ts +2 -0
  5. package/dist/client/BridgeProvider.svelte +31 -0
  6. package/dist/client/BridgeProvider.svelte.d.ts +8 -0
  7. package/dist/client/components/developer/ApiTokenManagement.svelte +1 -1
  8. package/dist/client/components/sdk-auth/LoginForm.svelte +10 -7
  9. package/dist/client/components/sdk-auth/LoginForm.svelte.d.ts +1 -1
  10. package/dist/client/components/sdk-auth/PasskeyLogin.svelte +1 -1
  11. package/dist/client/components/sdk-auth/PasskeySetup.svelte +1 -1
  12. package/dist/client/components/sdk-auth/SsoButton.svelte +1 -1
  13. package/dist/client/components/sdk-auth/SsoButton.svelte.d.ts +1 -1
  14. package/dist/client/components/sdk-auth/TenantSelector.svelte +1 -1
  15. package/dist/client/components/sdk-auth/TenantSelector.svelte.d.ts +1 -1
  16. package/dist/client/components/sdk-auth/WorkspaceSelector.svelte +3 -3
  17. package/dist/client/components/sdk-auth/WorkspaceSelector.svelte.d.ts +3 -3
  18. package/dist/client/components/subscription/BridgeBillingNotice.svelte +389 -0
  19. package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +19 -0
  20. package/dist/client/components/subscription/BridgePaywall.svelte +94 -0
  21. package/dist/client/components/subscription/BridgePaywall.svelte.d.ts +19 -0
  22. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +275 -0
  23. package/dist/client/components/subscription/BridgeQuotaBanner.svelte.d.ts +24 -0
  24. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte +116 -0
  25. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte.d.ts +7 -0
  26. package/dist/client/components/subscription/PlanSelector.svelte +22 -14
  27. package/dist/client/components/subscription/PlanSelector.svelte.d.ts +3 -3
  28. package/dist/client/components/subscription/billing-notice-gate.test.d.ts +1 -0
  29. package/dist/client/components/subscription/billing-notice-gate.test.js +83 -0
  30. package/dist/client/components/team/TeamAddUserDialog.svelte +1 -1
  31. package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +1 -1
  32. package/dist/client/components/team/TeamEditUserDialog.svelte +1 -1
  33. package/dist/client/components/team/TeamEditUserDialog.svelte.d.ts +1 -1
  34. package/dist/client/components/team/TeamProfileForm.svelte +1 -1
  35. package/dist/client/components/team/TeamUserList.svelte +1 -1
  36. package/dist/client/components/team/TeamWorkspaceForm.svelte +1 -1
  37. package/dist/client/tracking/pii-hashing.spec.js +4 -14
  38. package/dist/core/bridge-instance.d.ts +1 -2
  39. package/dist/core/bridge-instance.js +0 -4
  40. package/dist/core/bridge-runtime.d.ts +101 -0
  41. package/dist/core/bridge-runtime.js +290 -0
  42. package/dist/core/bridge-runtime.test.d.ts +1 -0
  43. package/dist/core/bridge-runtime.test.js +203 -0
  44. package/dist/core/bridge.d.ts +93 -0
  45. package/dist/core/bridge.js +108 -0
  46. package/dist/core/bridge.test.d.ts +1 -0
  47. package/dist/core/bridge.test.js +224 -0
  48. package/dist/core/events.d.ts +74 -0
  49. package/dist/core/events.js +64 -0
  50. package/dist/core/events.test.d.ts +1 -0
  51. package/dist/core/events.test.js +116 -0
  52. package/dist/core/lazy-slice.d.ts +74 -0
  53. package/dist/core/lazy-slice.js +124 -0
  54. package/dist/core/lazy-slice.test.d.ts +1 -0
  55. package/dist/core/lazy-slice.test.js +144 -0
  56. package/dist/core/realtime-status.d.ts +6 -0
  57. package/dist/core/realtime-status.js +15 -0
  58. package/dist/core/snapshot-stores.d.ts +69 -0
  59. package/dist/core/snapshot-stores.js +64 -0
  60. package/dist/core/use-bridge.d.ts +13 -0
  61. package/dist/core/use-bridge.js +46 -0
  62. package/dist/core/use-bridge.test.d.ts +1 -0
  63. package/dist/core/use-bridge.test.js +24 -0
  64. package/dist/flags/FeatureFlag.svelte +57 -0
  65. package/dist/flags/FeatureFlag.svelte.d.ts +37 -0
  66. package/dist/flags/bootstrap.d.ts +72 -0
  67. package/dist/flags/bootstrap.js +323 -0
  68. package/dist/flags/bootstrap.test.d.ts +1 -0
  69. package/dist/flags/bootstrap.test.js +195 -0
  70. package/dist/flags/flag.svelte.d.ts +31 -0
  71. package/dist/flags/flag.svelte.js +99 -0
  72. package/dist/flags/index.d.ts +8 -0
  73. package/dist/flags/index.js +20 -0
  74. package/dist/flags/index.test.d.ts +1 -0
  75. package/dist/flags/index.test.js +81 -0
  76. package/dist/flags/realtime-status.d.ts +1 -0
  77. package/dist/flags/realtime-status.js +4 -0
  78. package/dist/flags/registry.d.ts +27 -0
  79. package/dist/flags/registry.js +63 -0
  80. package/dist/flags/us13-integration.test.d.ts +1 -0
  81. package/dist/flags/us13-integration.test.js +168 -0
  82. package/dist/index.d.ts +14 -1
  83. package/dist/index.js +31 -2
  84. package/dist/styles.d.ts +2 -0
  85. package/package.json +92 -86
@@ -1,5 +1,4 @@
1
- import { describe, it, expect, beforeAll } from 'vitest';
2
- import { webcrypto } from 'node:crypto';
1
+ import { describe, it, expect } from 'vitest';
3
2
  import { sha256Email } from './pii-hashing.js';
4
3
  /**
5
4
  * Client-side PII hashing fixtures.
@@ -17,18 +16,9 @@ import { sha256Email } from './pii-hashing.js';
17
16
  const USER_AT_EXAMPLE_COM_HASH = 'b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514';
18
17
  // SHA-256 hex of "admin@gmail.com"
19
18
  const ADMIN_AT_GMAIL_COM_HASH = '7932b2e116b076a54f452848eaabd5857f61bd957fe8a218faf216f24c9885bb';
20
- /**
21
- * Vitest's `environment: 'node'` exposes Node's `webcrypto` at `globalThis.crypto`
22
- * from v19+, but the `digest()` API is the same as the browser `crypto.subtle`
23
- * that `sha256Email` expects. We defensively wire it up here so the test is
24
- * robust across Node versions.
25
- */
26
- beforeAll(() => {
27
- if (!globalThis.crypto || typeof globalThis.crypto.subtle?.digest !== 'function') {
28
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
- globalThis.crypto = webcrypto;
30
- }
31
- });
19
+ // Vitest's `environment: 'node'` exposes the Web Crypto API at
20
+ // `globalThis.crypto` (Node 19+), with the same `crypto.subtle.digest()` the
21
+ // browser provides and `sha256Email` expects.
32
22
  describe('sha256Email (client)', () => {
33
23
  it('hashes a plain, already-normalized email to the known SHA-256 hex (matches server fixture)', async () => {
34
24
  await expect(sha256Email('user@example.com')).resolves.toBe(USER_AT_EXAMPLE_COM_HASH);
@@ -45,7 +45,7 @@ export declare const flagsStore: Readable<Record<string, boolean>>;
45
45
  export declare const bridgeReadyStore: Readable<boolean>;
46
46
  /** App-level config (SSO providers, feature flags, etc.) — loaded anonymously on init */
47
47
  export declare const appConfigStore: Readable<AppConfig | null>;
48
- interface SubscriptionState {
48
+ export interface SubscriptionState {
49
49
  status: SubscriptionStatus | null;
50
50
  plans: Plan[] | null;
51
51
  loading: boolean;
@@ -59,4 +59,3 @@ export declare const auth: BridgeAuth;
59
59
  export declare const _flagsWritable: Writable<Record<string, boolean>>;
60
60
  export declare const _profileWritable: Writable<Profile | null | undefined>;
61
61
  export declare const _errorWritable: Writable<string | null>;
62
- export {};
@@ -84,10 +84,6 @@ export function initBridge(config) {
84
84
  _instance.on('auth:error', (err) => {
85
85
  _error.set(err.message);
86
86
  });
87
- // Load app config anonymously (drives SSO buttons, signup toggle, etc.).
88
- // Cached in `_appConfigPromise` so LoginForm (and others) can await the
89
- // result via `ensureAppConfig()` instead of racing the store update.
90
- void ensureAppConfig();
91
87
  logger.debug('[bridge-instance] initialized');
92
88
  return _instance;
93
89
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Bridge core runtime — the realtime + reactive-identity wiring that every
3
+ * Bridge capability (auth, flags, billing, ...) rides on top of.
4
+ *
5
+ * Until TBP-Live-Channel-Unification this lived inside `/flags/bootstrap.ts`,
6
+ * which was misleading: the realtime client, the per-channel auth scoping,
7
+ * the session.snapshot fanout and the billing-family event dispatch are not
8
+ * flag-specific. Hoisting them here lets `<BridgeBootstrap />` mount the
9
+ * runtime once and any capability (flags, billing) attach onto the same
10
+ * RealtimeClient instance.
11
+ *
12
+ * What this module does on `startBridgeRuntime()`:
13
+ *
14
+ * 1. Constructs a single `RealtimeClient` using `appId` + `apiBaseUrl` from
15
+ * `bridgeConfig.initConfig({...})` (auth's `getConfig()` is the single
16
+ * source of truth — no separate API base URL config for flags).
17
+ * 2. Calls `useBridge().attachToRealtimeClient(realtime)` so the billing
18
+ * stores (subscription, quotas, entitlements) react to live pushes.
19
+ * 3. Wires `setOnOpen` / `setOnClose` to mirror connection state into the
20
+ * reactive `realtimeStatus` store.
21
+ * 4. Wires `setOnSnapshot` to call `applySessionSnapshot(...)` (drives every
22
+ * `useBridge()` reactive store) and dispatch the snapshot event through
23
+ * `bridgeEvents`.
24
+ * 5. Wires `setOnUserState` so a server-side claims-change signal forces a
25
+ * `refreshTokens()` on BridgeAuth — the fresh JWT then flows back through
26
+ * the tokenStore subscription below.
27
+ * 6. Subscribes to `tokenStore`: identity tracking for the realtime channel
28
+ * (setAppId/setWorkspaceId/setUserId), explicit reauthorize on
29
+ * token-only refresh, and re-configuration of the `useBridge().quotas`
30
+ * HTTP options so quota hydrate requests carry the current access token.
31
+ * 7. Registers the canonical billing-family event handlers via
32
+ * `useBridge().handle({...})` so `subscription.*` / `payment.*` /
33
+ * `dunning.*` / `quota.updated` / `entitlements.changed` flow into
34
+ * `bridgeEvents._dispatch()`.
35
+ * 8. Exposes chainable `onBridgeRealtimeOpen` / `onBridgeRealtimeClose` /
36
+ * `onBridgeRealtimeSnapshot` subscriber sets so individual capability
37
+ * bootstrappers (flag attach, etc.) can layer their own behavior without
38
+ * clobbering the core handlers (`setOn*` on RealtimeClient is single-slot).
39
+ *
40
+ * `startBridgeRuntime()` is idempotent — repeated calls return the existing
41
+ * instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
42
+ * to flush the realtime client and unsubscribe from the token store.
43
+ */
44
+ import { RealtimeClient, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
45
+ /**
46
+ * Advanced runtime overrides. Product consumers never pass these; tests,
47
+ * Storybook harnesses, and the demo workspace use them to override the
48
+ * realtime transport (e.g. inject a debug-instrumented WebSocket) or watch
49
+ * `user.state_changed` signals for visualization.
50
+ */
51
+ export interface StartBridgeRuntimeOptions {
52
+ /**
53
+ * Pass-through realtime overrides. `apiBaseUrl`, `apiKey`, `appId`, and
54
+ * `getAuthToken` are owned by the runtime and ignored here.
55
+ */
56
+ realtime?: Partial<Omit<RealtimeClientConfig, 'apiBaseUrl' | 'apiKey' | 'appId' | 'getAuthToken'>>;
57
+ }
58
+ /**
59
+ * Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
60
+ * `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
61
+ * Must be called AFTER `bridgeConfig.initConfig({...})` runs — typically from
62
+ * `<BridgeBootstrap />`'s onMount.
63
+ */
64
+ export declare function startBridgeRuntime(options?: StartBridgeRuntimeOptions): void;
65
+ /**
66
+ * Stop the runtime. Idempotent — safe to call without a prior start. Flushes
67
+ * the realtime client and unsubscribes from the token store. Subscriber sets
68
+ * are NOT cleared so re-start picks up existing capability extensions.
69
+ */
70
+ export declare function stopBridgeRuntime(): Promise<void>;
71
+ /**
72
+ * Get the shared RealtimeClient. Returns `undefined` if `startBridgeRuntime()`
73
+ * hasn't run yet. Used by capability bootstrappers (e.g. flag attach) to
74
+ * register their own bridge/cache against the same channel.
75
+ */
76
+ export declare function getBridgeRealtime(): RealtimeClient | undefined;
77
+ /**
78
+ * Get the current access token cached for the realtime client's
79
+ * `getAuthToken` closure. Capability bootstrappers may need it for their own
80
+ * auth-sensitive operations (e.g. flag hydrate). Returns `undefined` when
81
+ * the user is logged out.
82
+ */
83
+ export declare function getCurrentAuthToken(): string | undefined;
84
+ /** Subscribe to realtime `open` events. Returns an unsubscribe fn. */
85
+ export declare function onBridgeRealtimeOpen(handler: () => void): () => void;
86
+ /** Subscribe to realtime `close` events. Returns an unsubscribe fn. */
87
+ export declare function onBridgeRealtimeClose(handler: () => void): () => void;
88
+ /** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
89
+ export declare function onBridgeRealtimeSnapshot(handler: (msg: SessionSnapshotMessage) => void): () => void;
90
+ /**
91
+ * Subscribe to server-side `user.state_changed` signals (fired before the
92
+ * runtime triggers a token refresh). Useful for debug overlays + tests.
93
+ */
94
+ export declare function onBridgeRealtimeUserState(handler: (event: {
95
+ reason: string;
96
+ }) => void): () => void;
97
+ /**
98
+ * Test-only — reset module-level state. Use between unit tests to avoid
99
+ * cross-test bleed in the realtime client + subscriber sets.
100
+ */
101
+ export declare function __resetBridgeRuntime(): void;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Bridge core runtime — the realtime + reactive-identity wiring that every
3
+ * Bridge capability (auth, flags, billing, ...) rides on top of.
4
+ *
5
+ * Until TBP-Live-Channel-Unification this lived inside `/flags/bootstrap.ts`,
6
+ * which was misleading: the realtime client, the per-channel auth scoping,
7
+ * the session.snapshot fanout and the billing-family event dispatch are not
8
+ * flag-specific. Hoisting them here lets `<BridgeBootstrap />` mount the
9
+ * runtime once and any capability (flags, billing) attach onto the same
10
+ * RealtimeClient instance.
11
+ *
12
+ * What this module does on `startBridgeRuntime()`:
13
+ *
14
+ * 1. Constructs a single `RealtimeClient` using `appId` + `apiBaseUrl` from
15
+ * `bridgeConfig.initConfig({...})` (auth's `getConfig()` is the single
16
+ * source of truth — no separate API base URL config for flags).
17
+ * 2. Calls `useBridge().attachToRealtimeClient(realtime)` so the billing
18
+ * stores (subscription, quotas, entitlements) react to live pushes.
19
+ * 3. Wires `setOnOpen` / `setOnClose` to mirror connection state into the
20
+ * reactive `realtimeStatus` store.
21
+ * 4. Wires `setOnSnapshot` to call `applySessionSnapshot(...)` (drives every
22
+ * `useBridge()` reactive store) and dispatch the snapshot event through
23
+ * `bridgeEvents`.
24
+ * 5. Wires `setOnUserState` so a server-side claims-change signal forces a
25
+ * `refreshTokens()` on BridgeAuth — the fresh JWT then flows back through
26
+ * the tokenStore subscription below.
27
+ * 6. Subscribes to `tokenStore`: identity tracking for the realtime channel
28
+ * (setAppId/setWorkspaceId/setUserId), explicit reauthorize on
29
+ * token-only refresh, and re-configuration of the `useBridge().quotas`
30
+ * HTTP options so quota hydrate requests carry the current access token.
31
+ * 7. Registers the canonical billing-family event handlers via
32
+ * `useBridge().handle({...})` so `subscription.*` / `payment.*` /
33
+ * `dunning.*` / `quota.updated` / `entitlements.changed` flow into
34
+ * `bridgeEvents._dispatch()`.
35
+ * 8. Exposes chainable `onBridgeRealtimeOpen` / `onBridgeRealtimeClose` /
36
+ * `onBridgeRealtimeSnapshot` subscriber sets so individual capability
37
+ * bootstrappers (flag attach, etc.) can layer their own behavior without
38
+ * clobbering the core handlers (`setOn*` on RealtimeClient is single-slot).
39
+ *
40
+ * `startBridgeRuntime()` is idempotent — repeated calls return the existing
41
+ * instance. Call `stopBridgeRuntime()` (e.g. on `<BridgeBootstrap />` destroy)
42
+ * to flush the realtime client and unsubscribe from the token store.
43
+ */
44
+ import { RealtimeClient, useBridge, } from '@nebulr-group/bridge-auth-core';
45
+ import { getConfig } from '../client/stores/config.store.js';
46
+ import { getBridgeAuth, tokenStore } from './bridge-instance.js';
47
+ import { applySessionSnapshot } from './snapshot-stores.js';
48
+ import { bridgeEvents } from './events.js';
49
+ import { _setRealtimeStatus } from './realtime-status.js';
50
+ let _realtime;
51
+ let _unsubscribeAuth;
52
+ let _currentAuthToken;
53
+ const _onOpenSubs = new Set();
54
+ const _onCloseSubs = new Set();
55
+ const _onSnapshotSubs = new Set();
56
+ const _onUserStateSubs = new Set();
57
+ /**
58
+ * Start the Bridge runtime. Idempotent — repeated calls are a no-op. Reads
59
+ * `appId` + `apiBaseUrl` from the auth config store (`bridgeConfig.initConfig`).
60
+ * Must be called AFTER `bridgeConfig.initConfig({...})` runs — typically from
61
+ * `<BridgeBootstrap />`'s onMount.
62
+ */
63
+ export function startBridgeRuntime(options = {}) {
64
+ if (_realtime)
65
+ return;
66
+ const config = getConfig();
67
+ // `appId` may come from BridgeAuth's API context if available; falls back
68
+ // to the value from `getConfig()`. The auth context one is what gets bound
69
+ // to the per-app channel scope at boot, before any user JWT arrives.
70
+ let _bootstrapAppId = config.appId;
71
+ try {
72
+ _bootstrapAppId = getBridgeAuth().getApiContext().appId ?? config.appId;
73
+ }
74
+ catch {
75
+ // BridgeAuth singleton not constructed yet — fall through with config.appId.
76
+ }
77
+ _realtime = new RealtimeClient({
78
+ ...(options.realtime ?? {}),
79
+ apiBaseUrl: config.apiBaseUrl ?? 'https://api.thebridge.dev',
80
+ apiKey: config.appId,
81
+ appId: _bootstrapAppId,
82
+ getAuthToken: () => _currentAuthToken,
83
+ });
84
+ _realtime.setOnOpen(() => {
85
+ _setRealtimeStatus('open');
86
+ for (const fn of _onOpenSubs) {
87
+ try {
88
+ fn();
89
+ }
90
+ catch { /* subscriber errors swallowed */ }
91
+ }
92
+ });
93
+ _realtime.setOnClose(() => {
94
+ _setRealtimeStatus('closed');
95
+ for (const fn of _onCloseSubs) {
96
+ try {
97
+ fn();
98
+ }
99
+ catch { /* subscriber errors swallowed */ }
100
+ }
101
+ });
102
+ _realtime.setOnSnapshot((msg) => {
103
+ try {
104
+ applySessionSnapshot(msg.data);
105
+ }
106
+ catch { /* store updates shouldn't throw, defensive */ }
107
+ bridgeEvents._dispatch(msg);
108
+ for (const fn of _onSnapshotSubs) {
109
+ try {
110
+ fn(msg);
111
+ }
112
+ catch { /* subscriber errors swallowed */ }
113
+ }
114
+ });
115
+ // user.state_changed → JWT refresh. The fresh tokens flow back through the
116
+ // tokenStore subscription below and re-bind channel scopes / re-eval flags.
117
+ _realtime.setOnUserState(async (msg) => {
118
+ for (const fn of _onUserStateSubs) {
119
+ try {
120
+ fn({ reason: msg.reason });
121
+ }
122
+ catch { /* subscriber errors swallowed */ }
123
+ }
124
+ try {
125
+ await getBridgeAuth().refreshTokens();
126
+ }
127
+ catch { /* next scheduled refresh will pick it up */ }
128
+ });
129
+ // Billing 2.0 US-11 — bind the billing stores to this realtime client so
130
+ // subscription / quotas / entitlements react to live pushes.
131
+ useBridge().attachToRealtimeClient(_realtime);
132
+ // Phase 5 (TBP-331) + TBP-360 — billing-family events flow through the
133
+ // unified bridge events surface via `useBridge().handle({...})`.
134
+ useBridge().handle({
135
+ 'subscription.plan_changed': (msg) => bridgeEvents._dispatch(msg),
136
+ 'payment.failed': (msg) => bridgeEvents._dispatch(msg),
137
+ 'payment.succeeded': (msg) => bridgeEvents._dispatch(msg),
138
+ 'subscription.created': (msg) => bridgeEvents._dispatch(msg),
139
+ 'subscription.updated': (msg) => bridgeEvents._dispatch(msg),
140
+ 'subscription.canceled': (msg) => bridgeEvents._dispatch(msg),
141
+ 'subscription.reactivated': (msg) => bridgeEvents._dispatch(msg),
142
+ 'subscription.trial_started': (msg) => bridgeEvents._dispatch(msg),
143
+ 'subscription.trial_ending_soon': (msg) => bridgeEvents._dispatch(msg),
144
+ 'subscription.trial_converted': (msg) => bridgeEvents._dispatch(msg),
145
+ 'subscription.trial_expired': (msg) => bridgeEvents._dispatch(msg),
146
+ 'dunning.entered': (msg) => bridgeEvents._dispatch(msg),
147
+ 'dunning.retry_scheduled': (msg) => bridgeEvents._dispatch(msg),
148
+ 'dunning.recovered': (msg) => bridgeEvents._dispatch(msg),
149
+ 'dunning.exhausted': (msg) => bridgeEvents._dispatch(msg),
150
+ 'quota.updated': (msg) => bridgeEvents._dispatch(msg),
151
+ 'entitlements.changed': (msg) => bridgeEvents._dispatch(msg),
152
+ });
153
+ // Token store subscription — owns realtime channel scoping + quotas HTTP
154
+ // options + reauthorize on token-only refresh. Capability-specific subs
155
+ // (e.g. flag eval context) are layered on top by their own bootstrappers.
156
+ const apiBaseUrl = config.apiBaseUrl ?? 'https://api.thebridge.dev';
157
+ _unsubscribeAuth = tokenStore.subscribe((tokens) => {
158
+ const prevAuthToken = _currentAuthToken;
159
+ _currentAuthToken = tokens?.accessToken;
160
+ // Quota store hydrate requests carry the current access token; configure
161
+ // here so post-login state is picked up. Best-effort: a missing
162
+ // BridgeAuth means SDK isn't fully initialized yet — skip.
163
+ try {
164
+ const auth = getBridgeAuth();
165
+ useBridge().quotas.configure({
166
+ apiBaseUrl,
167
+ appId: auth.getApiContext().appId,
168
+ accessToken: tokens?.accessToken ?? null,
169
+ });
170
+ }
171
+ catch {
172
+ // No BridgeAuth yet — quota hydration falls back to live pushes only.
173
+ }
174
+ if (!tokens?.accessToken) {
175
+ // Logout — drop user + workspace channel scopes. The app channel keeps
176
+ // its anonymous app-id auth.
177
+ _realtime.setUserId(undefined);
178
+ _realtime.setWorkspaceId(undefined);
179
+ return;
180
+ }
181
+ const claims = decodeJwtPayload(tokens.accessToken);
182
+ if (!claims)
183
+ return;
184
+ // Wire all three SDK channel scopes from the JWT. `aid` → app, `tid` →
185
+ // workspace, `sub` → user. Each setter is idempotent; only changes trigger
186
+ // a reconnect.
187
+ _realtime.setAppId(typeof claims.aid === 'string' ? claims.aid : undefined);
188
+ _realtime.setWorkspaceId(typeof claims.tid === 'string' ? claims.tid : undefined);
189
+ _realtime.setUserId(typeof claims.sub === 'string' ? claims.sub : undefined);
190
+ // Token-only refresh (same user, new JWT): setUserId is a no-op when
191
+ // userId hasn't changed, leaving the channel riding the OLD JWT until
192
+ // Centrifugo's connection-token TTL drops it. Force a reauthorize so the
193
+ // server re-validates against the new token immediately.
194
+ if (prevAuthToken && _currentAuthToken && prevAuthToken !== _currentAuthToken) {
195
+ void _realtime.reauthorize();
196
+ }
197
+ });
198
+ // Best-effort start. RealtimeClient gracefully no-ops if the workspace's
199
+ // `/realtime/config` returns `kind: 'noop'`.
200
+ void _realtime.start();
201
+ }
202
+ /**
203
+ * Stop the runtime. Idempotent — safe to call without a prior start. Flushes
204
+ * the realtime client and unsubscribes from the token store. Subscriber sets
205
+ * are NOT cleared so re-start picks up existing capability extensions.
206
+ */
207
+ export async function stopBridgeRuntime() {
208
+ if (_unsubscribeAuth) {
209
+ _unsubscribeAuth();
210
+ _unsubscribeAuth = undefined;
211
+ }
212
+ if (_realtime) {
213
+ try {
214
+ await _realtime.stop();
215
+ }
216
+ catch { /* already stopped, ignore */ }
217
+ _realtime = undefined;
218
+ }
219
+ _currentAuthToken = undefined;
220
+ }
221
+ /**
222
+ * Get the shared RealtimeClient. Returns `undefined` if `startBridgeRuntime()`
223
+ * hasn't run yet. Used by capability bootstrappers (e.g. flag attach) to
224
+ * register their own bridge/cache against the same channel.
225
+ */
226
+ export function getBridgeRealtime() {
227
+ return _realtime;
228
+ }
229
+ /**
230
+ * Get the current access token cached for the realtime client's
231
+ * `getAuthToken` closure. Capability bootstrappers may need it for their own
232
+ * auth-sensitive operations (e.g. flag hydrate). Returns `undefined` when
233
+ * the user is logged out.
234
+ */
235
+ export function getCurrentAuthToken() {
236
+ return _currentAuthToken;
237
+ }
238
+ /** Subscribe to realtime `open` events. Returns an unsubscribe fn. */
239
+ export function onBridgeRealtimeOpen(handler) {
240
+ _onOpenSubs.add(handler);
241
+ return () => _onOpenSubs.delete(handler);
242
+ }
243
+ /** Subscribe to realtime `close` events. Returns an unsubscribe fn. */
244
+ export function onBridgeRealtimeClose(handler) {
245
+ _onCloseSubs.add(handler);
246
+ return () => _onCloseSubs.delete(handler);
247
+ }
248
+ /** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
249
+ export function onBridgeRealtimeSnapshot(handler) {
250
+ _onSnapshotSubs.add(handler);
251
+ return () => _onSnapshotSubs.delete(handler);
252
+ }
253
+ /**
254
+ * Subscribe to server-side `user.state_changed` signals (fired before the
255
+ * runtime triggers a token refresh). Useful for debug overlays + tests.
256
+ */
257
+ export function onBridgeRealtimeUserState(handler) {
258
+ _onUserStateSubs.add(handler);
259
+ return () => _onUserStateSubs.delete(handler);
260
+ }
261
+ /**
262
+ * Test-only — reset module-level state. Use between unit tests to avoid
263
+ * cross-test bleed in the realtime client + subscriber sets.
264
+ */
265
+ export function __resetBridgeRuntime() {
266
+ _onOpenSubs.clear();
267
+ _onCloseSubs.clear();
268
+ _onSnapshotSubs.clear();
269
+ _onUserStateSubs.clear();
270
+ _currentAuthToken = undefined;
271
+ if (_unsubscribeAuth) {
272
+ _unsubscribeAuth();
273
+ _unsubscribeAuth = undefined;
274
+ }
275
+ _realtime = undefined;
276
+ }
277
+ // ── helpers ─────────────────────────────────────────────────────────────────
278
+ /** Decode a JWT payload without signature verification (client context only). */
279
+ function decodeJwtPayload(token) {
280
+ try {
281
+ const part = token.split('.')[1];
282
+ if (!part)
283
+ return null;
284
+ const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
285
+ return JSON.parse(json);
286
+ }
287
+ catch {
288
+ return null;
289
+ }
290
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,203 @@
1
+ // Unit tests for the hoisted Bridge core runtime.
2
+ //
3
+ // Covers the contract surface around `startBridgeRuntime` / subscriber sets /
4
+ // `getBridgeRealtime`. Heavy stubbing — auth-core's RealtimeClient + useBridge
5
+ // are mocked so we can drive open/close/snapshot/user-state events and assert
6
+ // they fan out to subscribers + the realtimeStatus store + bridgeEvents.
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import { writable, get } from 'svelte/store';
9
+ let _tokenStore;
10
+ // Captured event handlers so tests can fire them.
11
+ let _onOpen;
12
+ let _onClose;
13
+ let _onSnapshot;
14
+ let _onUserState;
15
+ const _channelScopeCalls = [];
16
+ const _reauthCalls = [];
17
+ let _startCalls = 0;
18
+ let _stopCalls = 0;
19
+ let _capturedRealtimeConfig;
20
+ // Reset the spy state between tests.
21
+ function resetSpies() {
22
+ _onOpen = _onClose = _onSnapshot = _onUserState = undefined;
23
+ _channelScopeCalls.length = 0;
24
+ _reauthCalls.length = 0;
25
+ _startCalls = 0;
26
+ _stopCalls = 0;
27
+ _capturedRealtimeConfig = undefined;
28
+ }
29
+ // ── Mocks ──────────────────────────────────────────────────────────────────
30
+ vi.mock('./bridge-instance.js', () => ({
31
+ get tokenStore() {
32
+ return _tokenStore;
33
+ },
34
+ getBridgeAuth: () => ({
35
+ getApiContext: () => ({ appId: 'app-1', accessToken: null }),
36
+ refreshTokens: async () => { },
37
+ }),
38
+ }));
39
+ vi.mock('../client/stores/config.store.js', () => ({
40
+ getConfig: () => ({ appId: 'app-1', apiBaseUrl: 'http://test' }),
41
+ }));
42
+ vi.mock('./snapshot-stores.js', () => ({
43
+ applySessionSnapshot: vi.fn(),
44
+ }));
45
+ vi.mock('./events.js', () => ({
46
+ bridgeEvents: { _dispatch: vi.fn() },
47
+ }));
48
+ vi.mock('@nebulr-group/bridge-auth-core', () => {
49
+ class FakeRealtimeClient {
50
+ constructor(config) {
51
+ _capturedRealtimeConfig = config;
52
+ }
53
+ setOnOpen(fn) { _onOpen = fn; }
54
+ setOnClose(fn) { _onClose = fn; }
55
+ setOnSnapshot(fn) { _onSnapshot = fn; }
56
+ setOnUserState(fn) { _onUserState = fn; }
57
+ setAppId(v) { _channelScopeCalls.push({ method: 'setAppId', value: v }); }
58
+ setWorkspaceId(v) { _channelScopeCalls.push({ method: 'setWorkspaceId', value: v }); }
59
+ setUserId(v) { _channelScopeCalls.push({ method: 'setUserId', value: v }); }
60
+ async reauthorize() { _reauthCalls.push(Date.now()); }
61
+ async start() { _startCalls++; }
62
+ async stop() { _stopCalls++; }
63
+ }
64
+ return {
65
+ RealtimeClient: FakeRealtimeClient,
66
+ useBridge: () => ({
67
+ quotas: { configure: vi.fn() },
68
+ attachToRealtimeClient: vi.fn(),
69
+ handle: vi.fn(() => () => { }),
70
+ }),
71
+ };
72
+ });
73
+ beforeEach(() => {
74
+ _tokenStore = writable(null);
75
+ resetSpies();
76
+ });
77
+ afterEach(async () => {
78
+ const { __resetBridgeRuntime, stopBridgeRuntime } = await import('./bridge-runtime.js');
79
+ await stopBridgeRuntime();
80
+ __resetBridgeRuntime();
81
+ });
82
+ // ── Imports under test ─────────────────────────────────────────────────────
83
+ import { startBridgeRuntime, stopBridgeRuntime, getBridgeRealtime, onBridgeRealtimeOpen, onBridgeRealtimeClose, onBridgeRealtimeSnapshot, onBridgeRealtimeUserState, } from './bridge-runtime.js';
84
+ import { realtimeStatus } from './realtime-status.js';
85
+ // ── Helpers ────────────────────────────────────────────────────────────────
86
+ function makeJwt(claims) {
87
+ const enc = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
88
+ return `${enc({ alg: 'HS256', typ: 'JWT' })}.${enc(claims)}.sig`;
89
+ }
90
+ // ── Tests ──────────────────────────────────────────────────────────────────
91
+ describe('startBridgeRuntime', () => {
92
+ it('is idempotent — repeated calls do not construct a second realtime client', () => {
93
+ startBridgeRuntime();
94
+ const first = getBridgeRealtime();
95
+ startBridgeRuntime();
96
+ const second = getBridgeRealtime();
97
+ expect(first).toBe(second);
98
+ expect(_startCalls).toBe(1);
99
+ });
100
+ it('reads apiBaseUrl + appId from getConfig()', () => {
101
+ startBridgeRuntime();
102
+ expect(_capturedRealtimeConfig?.apiBaseUrl).toBe('http://test');
103
+ expect(_capturedRealtimeConfig?.apiKey).toBe('app-1');
104
+ });
105
+ it('mirrors connection state into realtimeStatus on open/close', () => {
106
+ startBridgeRuntime();
107
+ _onOpen?.();
108
+ expect(get(realtimeStatus)).toBe('open');
109
+ _onClose?.();
110
+ expect(get(realtimeStatus)).toBe('closed');
111
+ });
112
+ });
113
+ describe('chainable subscriber sets', () => {
114
+ it('fans out open events to every subscriber', () => {
115
+ const a = vi.fn();
116
+ const b = vi.fn();
117
+ onBridgeRealtimeOpen(a);
118
+ onBridgeRealtimeOpen(b);
119
+ startBridgeRuntime();
120
+ _onOpen?.();
121
+ expect(a).toHaveBeenCalledTimes(1);
122
+ expect(b).toHaveBeenCalledTimes(1);
123
+ });
124
+ it('unsubscribes cleanly', () => {
125
+ const a = vi.fn();
126
+ const unsub = onBridgeRealtimeOpen(a);
127
+ startBridgeRuntime();
128
+ unsub();
129
+ _onOpen?.();
130
+ expect(a).not.toHaveBeenCalled();
131
+ });
132
+ it('isolates subscriber errors — one throwing handler does not block others', () => {
133
+ const a = vi.fn(() => { throw new Error('boom'); });
134
+ const b = vi.fn();
135
+ onBridgeRealtimeOpen(a);
136
+ onBridgeRealtimeOpen(b);
137
+ startBridgeRuntime();
138
+ _onOpen?.();
139
+ expect(a).toHaveBeenCalled();
140
+ expect(b).toHaveBeenCalled();
141
+ });
142
+ it('dispatches session.snapshot to bridgeEvents + applySessionSnapshot + subscribers', async () => {
143
+ const { bridgeEvents } = await import('./events.js');
144
+ const { applySessionSnapshot } = await import('./snapshot-stores.js');
145
+ const sub = vi.fn();
146
+ onBridgeRealtimeSnapshot(sub);
147
+ startBridgeRuntime();
148
+ _onSnapshot?.({ kind: 'session.snapshot', data: { user: { id: 'u' } } });
149
+ expect(applySessionSnapshot).toHaveBeenCalledWith({ user: { id: 'u' } });
150
+ expect(bridgeEvents._dispatch).toHaveBeenCalled();
151
+ expect(sub).toHaveBeenCalled();
152
+ });
153
+ it('forwards user.state_changed events to onBridgeRealtimeUserState subscribers', async () => {
154
+ const sub = vi.fn();
155
+ onBridgeRealtimeUserState(sub);
156
+ startBridgeRuntime();
157
+ await _onUserState?.({ reason: 'role.changed' });
158
+ expect(sub).toHaveBeenCalledWith({ reason: 'role.changed' });
159
+ });
160
+ });
161
+ describe('token store subscription', () => {
162
+ it('binds channel scopes from JWT claims on login', () => {
163
+ startBridgeRuntime();
164
+ // `tokenStore.subscribe` fires immediately with the current value (null)
165
+ // — that emits a `setUserId(undefined)` first. Clear so we only see the
166
+ // login emission.
167
+ _channelScopeCalls.length = 0;
168
+ _tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', tid: 'tenant-7', aid: 'app-1' }) });
169
+ const userCall = _channelScopeCalls.find((c) => c.method === 'setUserId');
170
+ const tenantCall = _channelScopeCalls.find((c) => c.method === 'setWorkspaceId');
171
+ expect(userCall?.value).toBe('user-1');
172
+ expect(tenantCall?.value).toBe('tenant-7');
173
+ });
174
+ it('clears user + workspace scopes on logout but keeps app scope', () => {
175
+ startBridgeRuntime();
176
+ _tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', tid: 'tenant-7' }) });
177
+ _channelScopeCalls.length = 0;
178
+ _tokenStore.set(null);
179
+ expect(_channelScopeCalls).toEqual([
180
+ { method: 'setUserId', value: undefined },
181
+ { method: 'setWorkspaceId', value: undefined },
182
+ ]);
183
+ });
184
+ it('reauthorizes on token-only refresh (same user, new JWT)', () => {
185
+ startBridgeRuntime();
186
+ _tokenStore.set({ accessToken: makeJwt({ sub: 'user-1' }) });
187
+ _reauthCalls.length = 0;
188
+ _tokenStore.set({ accessToken: makeJwt({ sub: 'user-1', iat: 123 }) });
189
+ expect(_reauthCalls.length).toBe(1);
190
+ });
191
+ });
192
+ describe('stopBridgeRuntime', () => {
193
+ it('flushes the realtime client and clears the singleton', async () => {
194
+ startBridgeRuntime();
195
+ expect(getBridgeRealtime()).toBeDefined();
196
+ await stopBridgeRuntime();
197
+ expect(getBridgeRealtime()).toBeUndefined();
198
+ expect(_stopCalls).toBe(1);
199
+ });
200
+ it('is safe to call without a prior start', async () => {
201
+ await expect(stopBridgeRuntime()).resolves.not.toThrow();
202
+ });
203
+ });