@nebulr-group/bridge-svelte 0.7.0 → 0.8.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.
@@ -20,6 +20,18 @@
20
20
  stopBridgeRuntime,
21
21
  type StartBridgeRuntimeOptions,
22
22
  } from '../core/bridge-runtime.js';
23
+ import RealtimeDevBadge from './components/developer/RealtimeDevBadge.svelte';
24
+
25
+ // TBP-644 — the "Live updates off — why?" badge is mounted here so every app
26
+ // gets it without code changes. It renders in development builds only;
27
+ // `devBadge: false` in the config turns it off there too.
28
+ const devBadgeEnabled = (() => {
29
+ try {
30
+ return getConfig().devBadge !== false;
31
+ } catch {
32
+ return true;
33
+ }
34
+ })();
23
35
 
24
36
  // Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
25
37
  // reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
@@ -194,3 +206,5 @@
194
206
  await handleRoute(to.url.pathname, cancel, to.url.search);
195
207
  });
196
208
  </script>
209
+
210
+ <RealtimeDevBadge enabled={devBadgeEnabled} />
@@ -0,0 +1,216 @@
1
+ <!--
2
+ TBP-644 — development-only "Live updates off — why?" corner badge.
3
+
4
+ <BridgeBootstrap /> mounts this automatically. It renders only in
5
+ development builds (`dev` from `$app/environment`, which SvelteKit resolves
6
+ to a constant `false` in production builds) and only while live updates are
7
+ actually off: refused (`unauthorized`), connected but deaf (`degraded`), or
8
+ retrying for longer than 30 s. Opt out with `devBadge: false` in
9
+ `bridgeConfig.initConfig({...})`.
10
+
11
+ Styling is self-contained — every element resets with `all: unset` — so it
12
+ neither depends on nor inherits from the host app's CSS.
13
+ -->
14
+ <script lang="ts">
15
+ import { dev } from '$app/environment';
16
+ import { realtimeStatusDetail } from '../../../core/realtime-status.js';
17
+ import {
18
+ REALTIME_BADGE_RETRYING_AFTER_MS,
19
+ createRetryClock,
20
+ realtimeBadgeView,
21
+ } from '../../../core/realtime-dev-badge.js';
22
+
23
+ let { enabled = true }: { enabled?: boolean } = $props();
24
+
25
+ const PANEL_ID = 'bridge-realtime-dev-badge-panel';
26
+ const retryClock = createRetryClock();
27
+
28
+ let now = $state(Date.now());
29
+ let expanded = $state(false);
30
+ let dismissedKey = $state<string | undefined>(undefined);
31
+ let toggleEl = $state<HTMLButtonElement | undefined>(undefined);
32
+
33
+ const status = $derived($realtimeStatusDetail);
34
+ // Start of the current retry run — stable across the closed/connecting
35
+ // flips inside one episode (see createRetryClock).
36
+ const retryingSince = $derived(retryClock(status, Date.now()));
37
+
38
+ // Tick `now` once the retrying threshold passes — nothing else re-renders.
39
+ $effect(() => {
40
+ if (retryingSince === undefined) return;
41
+ const remaining = retryingSince + REALTIME_BADGE_RETRYING_AFTER_MS - Date.now();
42
+ const timer = setTimeout(() => (now = Date.now()), Math.max(0, remaining) + 50);
43
+ return () => clearTimeout(timer);
44
+ });
45
+
46
+ const view = $derived(realtimeBadgeView(status, retryingSince, now));
47
+ const visible = $derived(!!view && view.key !== dismissedKey);
48
+
49
+ function dismiss() {
50
+ dismissedKey = view?.key;
51
+ expanded = false;
52
+ }
53
+
54
+ function onKeydown(event: KeyboardEvent) {
55
+ if (event.key === 'Escape' && expanded) {
56
+ expanded = false;
57
+ toggleEl?.focus();
58
+ }
59
+ }
60
+ </script>
61
+
62
+ {#if dev && enabled}
63
+ <div class="bridge-rt-root" data-testid="bridge-realtime-dev-badge-root">
64
+ <span class="bridge-rt-sr" role="status" aria-live="polite"
65
+ >{visible && view ? `Bridge live updates are off: ${view.reason}` : ''}</span
66
+ >
67
+ {#if visible && view}
68
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
69
+ <aside
70
+ class="bridge-rt-badge"
71
+ aria-label="Bridge live updates (development only)"
72
+ data-testid="bridge-realtime-dev-badge"
73
+ onkeydown={onKeydown}
74
+ >
75
+ <div class="bridge-rt-bar">
76
+ <button
77
+ bind:this={toggleEl}
78
+ type="button"
79
+ class="bridge-rt-toggle"
80
+ aria-expanded={expanded}
81
+ aria-controls={PANEL_ID}
82
+ onclick={() => (expanded = !expanded)}
83
+ >
84
+ <span class="bridge-rt-dot" aria-hidden="true">●</span> Live updates off — why?
85
+ </button>
86
+ <button
87
+ type="button"
88
+ class="bridge-rt-close"
89
+ aria-label="Dismiss the live updates notice"
90
+ onclick={dismiss}>×</button
91
+ >
92
+ </div>
93
+ {#if expanded}
94
+ <div id={PANEL_ID} class="bridge-rt-panel">
95
+ <div class="bridge-rt-row">
96
+ <span class="bridge-rt-label">Reason</span>
97
+ <code class="bridge-rt-code">{view.reason}</code>
98
+ </div>
99
+ <div class="bridge-rt-row">
100
+ <span class="bridge-rt-label">Whose side</span>
101
+ <span class="bridge-rt-value">{view.sideLabel}</span>
102
+ </div>
103
+ {#if view.ref}
104
+ <div class="bridge-rt-row">
105
+ <span class="bridge-rt-label">Ref</span>
106
+ <code class="bridge-rt-code">{view.ref}</code>
107
+ </div>
108
+ {/if}
109
+ {#if view.docsUrl}
110
+ <a class="bridge-rt-link" href={view.docsUrl} target="_blank" rel="noopener noreferrer"
111
+ >How to fix this ↗</a
112
+ >
113
+ {/if}
114
+ <span class="bridge-rt-note">Development builds only — never shown to your users.</span>
115
+ </div>
116
+ {/if}
117
+ </aside>
118
+ {/if}
119
+ </div>
120
+ {/if}
121
+
122
+ <style>
123
+ .bridge-rt-root,
124
+ .bridge-rt-root * {
125
+ all: unset;
126
+ box-sizing: border-box;
127
+ }
128
+ .bridge-rt-root {
129
+ position: fixed;
130
+ right: 16px;
131
+ bottom: 16px;
132
+ z-index: 2147483000;
133
+ display: block;
134
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
135
+ font-size: 12px;
136
+ line-height: 1.4;
137
+ color: #f5f5f5;
138
+ }
139
+ .bridge-rt-sr {
140
+ position: absolute;
141
+ width: 1px;
142
+ height: 1px;
143
+ overflow: hidden;
144
+ clip: rect(0 0 0 0);
145
+ white-space: nowrap;
146
+ }
147
+ .bridge-rt-badge {
148
+ display: block;
149
+ max-width: min(360px, calc(100vw - 32px));
150
+ background: #1f2328;
151
+ border: 1px solid #3d444d;
152
+ border-radius: 8px;
153
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
154
+ }
155
+ .bridge-rt-bar {
156
+ display: flex;
157
+ align-items: center;
158
+ }
159
+ .bridge-rt-toggle,
160
+ .bridge-rt-close {
161
+ cursor: pointer;
162
+ padding: 6px 10px;
163
+ border-radius: 8px;
164
+ }
165
+ .bridge-rt-toggle {
166
+ flex: 1;
167
+ }
168
+ .bridge-rt-close {
169
+ font-size: 14px;
170
+ line-height: 1;
171
+ color: #b0b8c1;
172
+ }
173
+ .bridge-rt-toggle:focus-visible,
174
+ .bridge-rt-close:focus-visible,
175
+ .bridge-rt-link:focus-visible {
176
+ outline: 2px solid #58a6ff;
177
+ outline-offset: 1px;
178
+ }
179
+ .bridge-rt-dot {
180
+ color: #f85149;
181
+ }
182
+ .bridge-rt-panel {
183
+ display: block;
184
+ padding: 4px 10px 10px;
185
+ border-top: 1px solid #3d444d;
186
+ }
187
+ .bridge-rt-row {
188
+ display: flex;
189
+ gap: 8px;
190
+ padding-top: 6px;
191
+ }
192
+ .bridge-rt-label {
193
+ flex: 0 0 72px;
194
+ color: #9198a1;
195
+ }
196
+ .bridge-rt-value {
197
+ flex: 1;
198
+ }
199
+ .bridge-rt-code {
200
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
201
+ overflow-wrap: anywhere;
202
+ }
203
+ .bridge-rt-link {
204
+ display: inline-block;
205
+ margin-top: 8px;
206
+ color: #58a6ff;
207
+ text-decoration: underline;
208
+ cursor: pointer;
209
+ }
210
+ .bridge-rt-note {
211
+ display: block;
212
+ margin-top: 8px;
213
+ color: #9198a1;
214
+ font-size: 11px;
215
+ }
216
+ </style>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ enabled?: boolean;
3
+ };
4
+ declare const RealtimeDevBadge: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type RealtimeDevBadge = ReturnType<typeof RealtimeDevBadge>;
6
+ export default RealtimeDevBadge;
@@ -239,6 +239,35 @@
239
239
  {:else if currentAuthState === 'tenant-selection'}
240
240
  <TenantSelector onError={onError} {messages} />
241
241
 
242
+ <!--
243
+ Settling: the session is real and the host app has not navigated yet.
244
+
245
+ This branch is `!== 'unauthenticated'` rather than an explicit list of
246
+ `authenticated | credentials-validated` on purpose. Those two used to fall
247
+ through to the credentials form below, so somebody who had just typed their
248
+ password correctly was shown the password form again — which reads as a
249
+ refusal, and the reasonable response is to type it again (TBP-635). The window
250
+ opens when the token exchange resolves and closes only when the consumer's
251
+ router lands, because LoginForm fires `onLogin` and deliberately does not
252
+ navigate. Measured at 600ms against a local stack; longer anywhere real.
253
+
254
+ Listing the two states would fix the two we know about and leave the next
255
+ `AuthState` member falling into the same hole. Inverting the test means the
256
+ credentials form renders ONLY for `unauthenticated`, and anything else lands
257
+ on a spinner — wrong-but-harmless instead of wrong-and-alarming.
258
+
259
+ `login.submitting` is reused rather than given its own key: it already says
260
+ "Signing in…" in all twelve locales, and a second key rendering the same words
261
+ would be a translation burden that buys nothing.
262
+ -->
263
+ {:else if currentAuthState !== 'unauthenticated'}
264
+ <AuthFormWrapper heading={null} class={className} {style} {...rest}>
265
+ <div class="bridge-auth-settling" data-bridge-auth-settling>
266
+ <Spinner size={24} />
267
+ <span>{t('login.submitting')}</span>
268
+ </div>
269
+ </AuthFormWrapper>
270
+
242
271
  <!-- Inline forgot password -->
243
272
  {:else if step === 'forgot-password'}
244
273
  <AuthFormWrapper heading={fpEmailSent ? null : t('forgot.headingRequest')}>
@@ -0,0 +1,27 @@
1
+ /**
2
+ * TBP-635 — LoginForm must never draw the credentials form to somebody who has
3
+ * already authenticated.
4
+ *
5
+ * The top-level branch named `mfa-required`, `mfa-setup-required` and
6
+ * `tenant-selection`, and let everything else fall through to the password
7
+ * form. `authenticated` and `credentials-validated` are also "everything else",
8
+ * so between the token exchange resolving and the host app's router landing —
9
+ * LoginForm fires `onLogin` and deliberately does not navigate — the component
10
+ * showed a password field to a user who had just typed their password
11
+ * correctly. That reads as a refusal. Measured at 600ms against a local stack.
12
+ *
13
+ * ## What this file can and cannot prove
14
+ *
15
+ * The ticket's suggested test is a MutationObserver recording every DOM batch
16
+ * during a real login, because a sampling loop can miss the race. This
17
+ * workspace has no DOM (see auth-form-description.test.ts for why), so that
18
+ * exact test is not available here and belongs in the Playwright e2e suite.
19
+ *
20
+ * What IS available is stronger in the dimension that actually caused the bug.
21
+ * The defect was not a timing subtlety — it was an unhandled branch. So this
22
+ * enumerates EVERY member of `AuthState`, read out of auth-core's shipped
23
+ * type declaration rather than hardcoded, and asserts that exactly one of them
24
+ * renders a password field. A seventh state added to auth-core tomorrow is
25
+ * covered the moment it exists, which is the half a race-detector would miss.
26
+ */
27
+ export {};
@@ -0,0 +1,219 @@
1
+ /**
2
+ * TBP-635 — LoginForm must never draw the credentials form to somebody who has
3
+ * already authenticated.
4
+ *
5
+ * The top-level branch named `mfa-required`, `mfa-setup-required` and
6
+ * `tenant-selection`, and let everything else fall through to the password
7
+ * form. `authenticated` and `credentials-validated` are also "everything else",
8
+ * so between the token exchange resolving and the host app's router landing —
9
+ * LoginForm fires `onLogin` and deliberately does not navigate — the component
10
+ * showed a password field to a user who had just typed their password
11
+ * correctly. That reads as a refusal. Measured at 600ms against a local stack.
12
+ *
13
+ * ## What this file can and cannot prove
14
+ *
15
+ * The ticket's suggested test is a MutationObserver recording every DOM batch
16
+ * during a real login, because a sampling loop can miss the race. This
17
+ * workspace has no DOM (see auth-form-description.test.ts for why), so that
18
+ * exact test is not available here and belongs in the Playwright e2e suite.
19
+ *
20
+ * What IS available is stronger in the dimension that actually caused the bug.
21
+ * The defect was not a timing subtlety — it was an unhandled branch. So this
22
+ * enumerates EVERY member of `AuthState`, read out of auth-core's shipped
23
+ * type declaration rather than hardcoded, and asserts that exactly one of them
24
+ * renders a password field. A seventh state added to auth-core tomorrow is
25
+ * covered the moment it exists, which is the half a race-detector would miss.
26
+ */
27
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
28
+ import { createServer } from 'vite';
29
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
30
+ import { render } from 'svelte/server';
31
+ import {} from 'svelte';
32
+ import { en, sv } from '@nebulr-group/bridge-auth-core';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ const ROOT = process.cwd();
36
+ const COMPONENT_DIR = 'src/lib/client/components/sdk-auth';
37
+ const SEED_PREFIX = '__seeded-settling-';
38
+ /**
39
+ * Every `AuthState`, parsed out of the auth-core declaration the package
40
+ * actually ships.
41
+ *
42
+ * Deliberately not a literal list: a hardcoded one would still say six when
43
+ * auth-core says seven, and the bug being fixed is precisely a state nobody
44
+ * remembered to handle. Parsing throws rather than returning a short list, so a
45
+ * refactor that moves the type fails this suite loudly instead of quietly
46
+ * testing fewer states.
47
+ */
48
+ function authStates() {
49
+ // Resolved from the filesystem rather than the module system: the package
50
+ // exports no `./package.json` subpath, and vitest's module runner supports
51
+ // neither `require.resolve` nor `import.meta.resolve` here. Both candidate
52
+ // paths are tried because bun may hoist the dep to the workspace root.
53
+ const candidates = [
54
+ path.join(ROOT, 'node_modules/@nebulr-group/bridge-auth-core/dist/types.d.ts'),
55
+ path.join(ROOT, '../node_modules/@nebulr-group/bridge-auth-core/dist/types.d.ts'),
56
+ ];
57
+ const dts = candidates.find((c) => fs.existsSync(c));
58
+ if (!dts) {
59
+ throw new Error(`auth-core types.d.ts not found. Looked in:\n ${candidates.join('\n ')}`);
60
+ }
61
+ const source = fs.readFileSync(dts, 'utf8');
62
+ const match = source.match(/export type AuthState\s*=([^;]+);/);
63
+ if (!match) {
64
+ throw new Error('Could not find `export type AuthState` in auth-core types.d.ts.');
65
+ }
66
+ const states = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
67
+ if (states.length < 2) {
68
+ throw new Error(`Parsed only ${states.length} AuthState members — parser is wrong.`);
69
+ }
70
+ return states;
71
+ }
72
+ const STATES = authStates();
73
+ let server;
74
+ const seededPaths = [];
75
+ beforeAll(async () => {
76
+ for (const f of fs.readdirSync(path.join(ROOT, COMPONENT_DIR))) {
77
+ if (f.startsWith(SEED_PREFIX))
78
+ fs.unlinkSync(path.join(ROOT, COMPONENT_DIR, f));
79
+ }
80
+ server = await createServer({
81
+ configFile: false,
82
+ root: ROOT,
83
+ logLevel: 'error',
84
+ server: { middlewareMode: true, hmr: false },
85
+ plugins: [svelte({ compilerOptions: { dev: false } })],
86
+ });
87
+ }, 120_000);
88
+ afterAll(async () => {
89
+ await server?.close();
90
+ for (const p of seededPaths) {
91
+ if (fs.existsSync(p))
92
+ fs.unlinkSync(p);
93
+ }
94
+ });
95
+ /**
96
+ * Render LoginForm with `currentAuthState` pinned.
97
+ *
98
+ * The seed replaces the one `$derived($authState)` line, so the template — every
99
+ * branch, the wrapper call, the catalogue lookups — is the real shipped source.
100
+ * Driving the store itself would need the network and the real state machine,
101
+ * and would test auth-core's transitions rather than this component's branching,
102
+ * which is where the defect was.
103
+ */
104
+ async function renderAtState(state, props = {}) {
105
+ const file = 'LoginForm.svelte';
106
+ const from = 'let currentAuthState = $derived($authState);';
107
+ let source = fs.readFileSync(path.join(ROOT, COMPONENT_DIR, file), 'utf8');
108
+ if (!source.includes(from)) {
109
+ throw new Error(`State seed target not found in ${file}:\n ${from}\n` +
110
+ 'The component was refactored. Update the seed so the test keeps driving ' +
111
+ 'the auth-state branch — do not delete the assertion.');
112
+ }
113
+ source = source.replace(from, `let currentAuthState = $state('${state}');`);
114
+ const name = `${SEED_PREFIX}${process.pid}-${seededPaths.length}-${file}`;
115
+ const full = path.join(ROOT, COMPONENT_DIR, name);
116
+ fs.writeFileSync(full, source);
117
+ seededPaths.push(full);
118
+ const mod = await server.ssrLoadModule(`/${COMPONENT_DIR}/${name}`);
119
+ const Component = mod.default;
120
+ return render(Component, { props }).body;
121
+ }
122
+ async function setLocale(locale) {
123
+ const mod = await server.ssrLoadModule('/src/lib/client/stores/config.store.ts');
124
+ mod.bridgeConfig.initConfig({ appId: 'tbp-635-test', locale });
125
+ }
126
+ /** A password field is the thing that must never appear post-auth. */
127
+ function hasPasswordField(html) {
128
+ return /<input[^>]+type="password"/.test(html);
129
+ }
130
+ function text(html) {
131
+ return html
132
+ .replace(/<!--[\s\S]*?-->/g, '')
133
+ .replace(/<[^>]*>/g, ' ')
134
+ .replace(/\s+/g, ' ')
135
+ .trim();
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // 1. The fixture itself
139
+ // ---------------------------------------------------------------------------
140
+ describe('AuthState coverage (TBP-635)', () => {
141
+ it('reads the real state list out of the shipped auth-core types', () => {
142
+ // If this drifts, every assertion below silently covers less.
143
+ expect(STATES).toContain('unauthenticated');
144
+ expect(STATES).toContain('authenticated');
145
+ expect(STATES).toContain('credentials-validated');
146
+ expect(STATES.length).toBeGreaterThanOrEqual(6);
147
+ });
148
+ });
149
+ // ---------------------------------------------------------------------------
150
+ // 2. The invariant
151
+ // ---------------------------------------------------------------------------
152
+ describe('LoginForm never shows the credentials form post-auth (TBP-635)', () => {
153
+ it('renders a password field for `unauthenticated` and for nothing else', async () => {
154
+ await setLocale(undefined);
155
+ const withPassword = [];
156
+ for (const state of STATES) {
157
+ if (hasPasswordField(await renderAtState(state)))
158
+ withPassword.push(state);
159
+ }
160
+ // Both directions. Asserting only "authenticated has no password field"
161
+ // would pass for a component that rendered nothing at all, ever.
162
+ expect(withPassword).toEqual(['unauthenticated']);
163
+ });
164
+ for (const state of ['authenticated', 'credentials-validated']) {
165
+ it(`shows the settling card at "${state}"`, async () => {
166
+ await setLocale(undefined);
167
+ const html = await renderAtState(state);
168
+ expect(html).toContain('data-bridge-auth-settling');
169
+ expect(hasPasswordField(html)).toBe(false);
170
+ // Not a blank card: an empty box during a pause is its own bad message.
171
+ expect(text(html)).toContain(en['login.submitting']);
172
+ });
173
+ }
174
+ it('translates the waiting copy', async () => {
175
+ await setLocale('sv');
176
+ const html = await renderAtState('authenticated');
177
+ expect(text(html)).toContain(sv['login.submitting']);
178
+ expect(text(html)).not.toContain(en['login.submitting']);
179
+ });
180
+ it('suppresses the heading while settling, so no stale "Log in" survives', async () => {
181
+ await setLocale(undefined);
182
+ const html = await renderAtState('authenticated', { heading: 'Log in to NorthWhistle' });
183
+ expect(html).not.toContain('Log in to NorthWhistle');
184
+ expect(html).toContain('data-bridge-auth-settling');
185
+ });
186
+ it('still hands the three delegated states to their own components', async () => {
187
+ // The fix must not have swallowed the branches that already worked — an
188
+ // over-eager `!== unauthenticated` placed above them would do exactly that.
189
+ await setLocale(undefined);
190
+ expect(await renderAtState('tenant-selection')).toContain('bridge-tenant-list');
191
+ expect(await renderAtState('mfa-required')).toContain(en['mfa.challengeHeading']);
192
+ expect(await renderAtState('mfa-setup-required')).toContain(en['mfaSetup.heading']);
193
+ });
194
+ it('leaves the unauthenticated step machine alone', async () => {
195
+ await setLocale(undefined);
196
+ const html = await renderAtState('unauthenticated');
197
+ expect(hasPasswordField(html)).toBe(true);
198
+ expect(html).toContain(en['login.submit']);
199
+ expect(html).not.toContain('data-bridge-auth-settling');
200
+ });
201
+ });
202
+ // ---------------------------------------------------------------------------
203
+ // 3. onLogin is untouched
204
+ // ---------------------------------------------------------------------------
205
+ describe('onLogin lifecycle is unchanged (TBP-635)', () => {
206
+ it('still fires from an effect keyed on `authenticated`', () => {
207
+ // Effects do not run in a server render, so this is asserted on the source.
208
+ // It is here because the tempting wrong fix for this bug is to move
209
+ // `onLogin` earlier so the consumer navigates sooner — which would fire it
210
+ // before the session is real.
211
+ const source = fs.readFileSync(path.join(ROOT, COMPONENT_DIR, 'LoginForm.svelte'), 'utf8');
212
+ const effect = source.match(/\$effect\(\(\) => \{[\s\S]*?\}\);/);
213
+ expect(effect, 'LoginForm no longer has an $effect').not.toBeNull();
214
+ expect(effect[0]).toContain("currentAuthState === 'authenticated'");
215
+ expect(effect[0]).toContain('onLogin?.()');
216
+ // Exactly one call site, so it cannot fire twice.
217
+ expect(source.match(/onLogin\?\.\(\)/g)).toHaveLength(1);
218
+ });
219
+ });
@@ -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 FlagChange, type RealtimeClientConfig, type SessionSnapshotMessage } from '@nebulr-group/bridge-auth-core';
44
+ import { RealtimeClient, type FlagChange, type RealtimeClientConfig, type RealtimeStatus, 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
@@ -105,6 +105,14 @@ export declare function onBridgeRealtimeClose(handler: () => void): () => void;
105
105
  export declare function onBridgeFlagChange(handler: (change: FlagChange) => void): () => void;
106
106
  /** Subscribe to `session.snapshot` messages. Returns an unsubscribe fn. */
107
107
  export declare function onBridgeRealtimeSnapshot(handler: (msg: SessionSnapshotMessage) => void): () => void;
108
+ /**
109
+ * Subscribe to realtime status changes (TBP-644): state, the machine-readable
110
+ * reason, whose side a fault is on (`app` / `config` / `bridge` / `network`),
111
+ * whether the client is still retrying, a docs link and a support ref.
112
+ * Fires on every change, not with the current value — read
113
+ * `realtimeStatusDetail` for that. Returns an unsubscribe fn.
114
+ */
115
+ export declare function onBridgeRealtimeStatus(handler: (status: RealtimeStatus) => void): () => void;
108
116
  /**
109
117
  * Subscribe to server-side `user.state_changed` signals (fired before the
110
118
  * runtime triggers a token refresh). Useful for debug overlays + tests.