@nebulr-group/bridge-svelte 0.9.0-beta.4 → 0.9.0-beta.6

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,11 +1,19 @@
1
1
  import type { NavigationDecision } from '@nebulr-group/bridge-auth-core';
2
2
  export type { FlagRequirement, NavigationDecision, RouteGuard, RouteGuardConfig, RouteRule } from '@nebulr-group/bridge-auth-core';
3
+ /** TBP-756 — a route restriction and, for a feature flag, why. */
4
+ export interface Restriction {
5
+ to: string;
6
+ reason?: 'plan' | 'permission' | 'off' | 'rule' | 'rollout';
7
+ flag?: string;
8
+ feature?: string;
9
+ }
3
10
  export declare function createRouteGuard(flagsReady?: Promise<void>): {
4
11
  checkRouteRestrictions(pathname: string): Promise<string | null>;
5
12
  getNavigationDecision(pathname: string, attempted?: string): Promise<NavigationDecision>;
6
13
  isPublicRoute(pathname: string): boolean;
7
14
  isProtectedRoute(pathname: string): boolean;
8
15
  shouldRedirectToLogin(pathname: string): boolean;
16
+ checkRouteRestriction(pathname: string): Promise<import("@nebulr-group/bridge-auth-core").RouteRestriction | null>;
9
17
  getLoginRedirect(): string;
10
18
  resolveReturnTo(attempted: string | null | undefined): string | null;
11
19
  };
@@ -35,14 +35,24 @@ export function createRouteGuard(flagsReady) {
35
35
  for (let attempt = 1;; attempt++) {
36
36
  await settleAuthorizationChange(deadline);
37
37
  const generation = guardCacheGeneration();
38
- const redirectTo = await guard.checkRouteRestrictions(pathname);
38
+ const restriction = await readRestriction(pathname);
39
39
  if (generation === guardCacheGeneration())
40
- return redirectTo;
40
+ return restriction;
41
41
  dropFlagCache();
42
42
  if (attempt >= MAX_FRESH_READS)
43
- return redirectTo;
43
+ return restriction;
44
44
  }
45
45
  }
46
+ // TBP-756 — the restriction with its reason, from an auth-core that reports
47
+ // one; an older auth-core gives the bare redirect target.
48
+ async function readRestriction(pathname) {
49
+ const withReason = guard
50
+ .checkRouteRestriction;
51
+ if (typeof withReason === 'function')
52
+ return withReason.call(guard, pathname);
53
+ const to = await guard.checkRouteRestrictions(pathname);
54
+ return to ? { to } : null;
55
+ }
46
56
  function loginDecision(pathname, attempted) {
47
57
  // TBP-629 — the attempted target (path + query) rides along on every
48
58
  // login decision, including the fail-closed ones below.
@@ -107,7 +117,7 @@ export function createRouteGuard(flagsReady) {
107
117
  async checkRouteRestrictions(pathname) {
108
118
  const deadline = Date.now() + AUTHORIZATION_CHANGE_WAIT_MS;
109
119
  await flagsReady;
110
- return checkRestrictionsFresh(pathname, deadline);
120
+ return (await checkRestrictionsFresh(pathname, deadline))?.to ?? null;
111
121
  },
112
122
  async getNavigationDecision(pathname, attempted) {
113
123
  // TBP-654 — one bound for the whole decision, however many reads it takes.
@@ -124,9 +134,11 @@ export function createRouteGuard(flagsReady) {
124
134
  return loginDecision(pathname, attempted);
125
135
  }
126
136
  await flagsReady;
127
- const redirectTo = await checkRestrictionsFresh(pathname, deadline);
128
- if (redirectTo) {
129
- return { type: 'redirect', to: redirectTo };
137
+ const restriction = await checkRestrictionsFresh(pathname, deadline);
138
+ if (restriction) {
139
+ // TBP-756 — `reason: 'plan'` rides along so the adapter opens the
140
+ // upgrade dialog; everything else is the plain redirect it was.
141
+ return { type: 'redirect', ...restriction };
130
142
  }
131
143
  return { type: 'allow' };
132
144
  }
@@ -1,5 +1,6 @@
1
1
  // src/lib/bridge/bootstrap.ts
2
2
  import { error, redirect, isRedirect } from '@sveltejs/kit';
3
+ import { openFeatureUpgrade } from '../core/feature-upgrade.js';
3
4
  import { get } from 'svelte/store';
4
5
  import { createRouteGuard } from '../auth/route-guard.js';
5
6
  import { dropFlagCache, guardCacheGeneration } from '../auth/guard-cache.js';
@@ -431,7 +432,25 @@ async function enforceRouteGuard(url, flagsReady) {
431
432
  stashReturnTo(decision.returnTo);
432
433
  redirect(303, bridge.createLoginUrl());
433
434
  }
434
- if (decision.type === 'redirect' && url.pathname !== decision.to) {
435
- redirect(303, decision.to);
435
+ if (decision.type === 'redirect') {
436
+ upgradeForPlanDecision(url, decision);
437
+ if (url.pathname !== decision.to)
438
+ redirect(303, decision.to);
436
439
  }
437
440
  }
441
+ /**
442
+ * TBP-756 — a route whose feature flag is off because of the plan opens the
443
+ * upgrade dialog (the owner case "reaching a gated page"). On a client-side
444
+ * navigation the visitor stays on the page they came from (the browser still
445
+ * shows it while the target loads); on a first load there is no such page, so
446
+ * they go to the rule's `redirectTo` first and the dialog opens there.
447
+ * Server-side there is no dialog to open: the plain redirect stands.
448
+ */
449
+ function upgradeForPlanDecision(url, decision) {
450
+ if (decision.reason !== 'plan' || typeof window === 'undefined')
451
+ return;
452
+ openFeatureUpgrade({ flag: decision.flag, feature: decision.feature });
453
+ const here = window.location;
454
+ if (here.pathname !== url.pathname)
455
+ redirect(303, `${here.pathname}${here.search}`);
456
+ }
@@ -26,6 +26,7 @@
26
26
  import RealtimeDevBadge from './components/developer/RealtimeDevBadge.svelte';
27
27
  import BridgeUpgradeDialog from './components/subscription/BridgeUpgradeDialog.svelte';
28
28
  import { dismissQuotaRefusal, quotaRefusal } from '../core/quota-refusal.js';
29
+ import { dismissFeatureUpgrade, featureUpgrade, openFeatureUpgrade } from '../core/feature-upgrade.js';
29
30
  import { resolveUpgradeDialog, upgradeHrefFor } from './upgrade-dialog.js';
30
31
  import { isBillingAdmin } from './billing-role.js';
31
32
 
@@ -54,9 +55,26 @@
54
55
  })();
55
56
  const upgradeDialog = resolveUpgradeDialog(billingConfig);
56
57
  const UpgradeDialog = upgradeDialog === 'default' ? BridgeUpgradeDialog : upgradeDialog;
57
- const upgradeHref = $derived(upgradeHrefFor($quotaRefusal, billingConfig));
58
+ // TBP-756 — the same dialog in its feature variant: a plan-gated route, a
59
+ // <FeatureFlag> upgrade click, or a backend's 402 FEATURE_NOT_IN_PLAN. A plan
60
+ // limit refusal wins when both are pending.
61
+ const upgradeHref = $derived(upgradeHrefFor($quotaRefusal ?? $featureUpgrade, billingConfig));
58
62
  // Re-read for every refusal: the same owner rule as <BridgeQuotaBanner>.
59
- const canUpgrade = $derived($quotaRefusal ? isBillingAdmin() : false);
63
+ const canUpgrade = $derived($quotaRefusal || $featureUpgrade ? isBillingAdmin() : false);
64
+ const upgradeFeature = $derived(
65
+ $quotaRefusal ? null : ($featureUpgrade ? ($featureUpgrade.feature ?? $featureUpgrade.flag ?? '') : null),
66
+ );
67
+ // TBP-755/756 — the feature variant names the plans that include the
68
+ // feature, from the plan list. Load it once when that variant opens.
69
+ $effect(() => {
70
+ if (!$featureUpgrade || !$isAuthenticated) return;
71
+ const { plans, loading, error } = $subscriptionStore;
72
+ if (!plans && !loading && !error) loadSubscription().catch(() => { /* the dialog still opens, without plan names */ });
73
+ });
74
+ function closeUpgradeDialog(): void {
75
+ dismissQuotaRefusal();
76
+ dismissFeatureUpgrade();
77
+ }
60
78
 
61
79
  // Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
62
80
  // reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
@@ -170,6 +188,18 @@
170
188
  }
171
189
  return;
172
190
  }
191
+ // TBP-756 — a plan-gated route opens the upgrade dialog instead of
192
+ // silently bouncing. A navigation is handled by the route's load
193
+ // (bridgeBootstrap), which keeps the visitor where they were; here only the
194
+ // re-check of the page they are already on is left: it takes them to the
195
+ // rule's redirectTo (client-side, so the dialog survives) and opens it.
196
+ if (decision.type === 'redirect' && (decision as { reason?: string }).reason === 'plan') {
197
+ if (cancel) return;
198
+ const { flag, feature } = decision as { flag?: string; feature?: string };
199
+ openFeatureUpgrade({ flag, feature });
200
+ if (window.location.pathname !== decision.to) await goto(decision.to);
201
+ return;
202
+ }
173
203
  if (decision.type === 'redirect' && window.location.pathname !== decision.to) {
174
204
  if (cancel) cancel();
175
205
  window.location.href = decision.to;
@@ -283,7 +313,7 @@
283
313
  <RealtimeDevBadge enabled={devBadgeEnabled} />
284
314
 
285
315
  {#if UpgradeDialog}
286
- <UpgradeDialog refusal={$quotaRefusal} {upgradeHref} {canUpgrade} onclose={dismissQuotaRefusal} />
316
+ <UpgradeDialog refusal={$quotaRefusal} {upgradeHref} {canUpgrade} onclose={closeUpgradeDialog} feature={upgradeFeature} plans={$subscriptionStore.plans} />
287
317
  {/if}
288
318
 
289
319
  {#if runtimeAttached && $bridgeReadyStore}
@@ -11,22 +11,36 @@
11
11
  and receives the same props.
12
12
 
13
13
  Decoration only: the backend already refused the write. This explains why.
14
+
15
+ TBP-756 — with no refusal and a `feature` set, it opens in its feature
16
+ variant ("This feature isn't on your plan", naming the plans that include
17
+ it). BridgeBootstrap sets `feature` only after the person did something
18
+ gated; a page that merely renders a hidden feature never opens it.
14
19
  -->
15
20
  <script lang="ts">
16
21
  import type { BridgeUpgradeDialogProps } from '../../../shared/types/config.js';
17
22
  import { quotaMemberBody } from '../../billing-role.js';
23
+ import { plansIncludingFeature } from '../../upgrade-dialog.js';
18
24
 
19
- let { refusal, upgradeHref, canUpgrade, onclose }: BridgeUpgradeDialogProps = $props();
25
+ let { refusal, upgradeHref, canUpgrade, onclose, feature = null, plans = null }: BridgeUpgradeDialogProps = $props();
20
26
 
21
27
  let dialogEl: HTMLDialogElement | undefined = $state();
22
28
 
29
+ // TBP-756 — the feature variant: no plan-limit refusal, but a feature the
30
+ // plan does not include (a plan-gated route, a <FeatureFlag> upgrade click,
31
+ // a backend's 402 FEATURE_NOT_IN_PLAN).
32
+ const featureVariant = $derived(!refusal && feature != null);
33
+ const isOpen = $derived(!!refusal || featureVariant);
34
+
23
35
  $effect(() => {
24
36
  if (!dialogEl) return;
25
- if (refusal && !dialogEl.open) dialogEl.showModal();
26
- else if (!refusal && dialogEl.open) dialogEl.close();
37
+ if (isOpen && !dialogEl.open) dialogEl.showModal();
38
+ else if (!isOpen && dialogEl.open) dialogEl.close();
27
39
  });
28
40
 
29
41
  const hasNumbers = $derived(refusal?.used != null && refusal?.limit != null);
42
+ // TBP-755 — the plans that include the missing feature, from the plan list.
43
+ const includedIn = $derived(plansIncludingFeature(plans, feature));
30
44
  </script>
31
45
 
32
46
  <dialog
@@ -34,12 +48,40 @@
34
48
  class="bridge-team-dialog bridge-upgrade-dialog"
35
49
  data-bridge-upgrade-dialog
36
50
  data-metric={refusal?.metric}
51
+ data-variant={featureVariant ? 'feature' : refusal ? 'limit' : undefined}
52
+ data-feature={featureVariant ? feature : undefined}
37
53
  aria-labelledby="bridge-upgrade-dialog-title"
38
54
  onclose={() => {
39
- if (refusal) onclose();
55
+ if (isOpen) onclose();
40
56
  }}
41
57
  >
42
- {#if refusal}
58
+ {#if featureVariant}
59
+ <div class="bridge-team-dialog-content">
60
+ <h3 id="bridge-upgrade-dialog-title" class="bridge-team-dialog-title">This feature isn't on your plan</h3>
61
+ <p class="bridge-team-dialog-message" data-bridge-upgrade-dialog-message data-variant={canUpgrade ? 'admin' : 'member'}>
62
+ {#if canUpgrade}
63
+ Upgrade the plan to use it.
64
+ {:else}
65
+ Ask the workspace owner to upgrade the plan to use it.
66
+ {/if}
67
+ </p>
68
+ {#if includedIn.length > 0}
69
+ <p class="bridge-team-dialog-message" data-bridge-upgrade-dialog-included-in>
70
+ Included in: {includedIn.join(', ')}
71
+ </p>
72
+ {/if}
73
+ <div class="bridge-team-dialog-actions">
74
+ {#if canUpgrade}
75
+ <button type="button" class="bridge-btn bridge-btn-secondary" onclick={() => onclose()}>Not now</button>
76
+ <a class="bridge-btn bridge-btn-primary" href={upgradeHref} data-bridge-upgrade-dialog-cta onclick={() => onclose()}>
77
+ Upgrade plan
78
+ </a>
79
+ {:else}
80
+ <button type="button" class="bridge-btn bridge-btn-primary" onclick={() => onclose()}>OK</button>
81
+ {/if}
82
+ </div>
83
+ </div>
84
+ {:else if refusal}
43
85
  <div class="bridge-team-dialog-content">
44
86
  <h3 id="bridge-upgrade-dialog-title" class="bridge-team-dialog-title">You've reached your plan's limit</h3>
45
87
  <p class="bridge-team-dialog-message" data-bridge-upgrade-dialog-message data-variant={canUpgrade ? 'admin' : 'member'}>
@@ -54,6 +96,11 @@
54
96
  {/if}
55
97
  {#if canUpgrade}Upgrade the plan to keep going.{/if}
56
98
  </p>
99
+ {#if includedIn.length > 0}
100
+ <p class="bridge-team-dialog-message" data-bridge-upgrade-dialog-included-in>
101
+ Included in: {includedIn.join(', ')}
102
+ </p>
103
+ {/if}
57
104
  <div class="bridge-team-dialog-actions">
58
105
  {#if canUpgrade}
59
106
  <button type="button" class="bridge-btn bridge-btn-secondary" onclick={() => onclose()}>Not now</button>
@@ -126,6 +126,12 @@
126
126
  [...(plans ?? [])].sort((a, b) => minAmount(a) - minAmount(b)),
127
127
  );
128
128
 
129
+ // TBP-755 — the features the plan includes; the same list the upgrade dialog
130
+ // reads. Structural: an older auth-core `Plan` type has no `features`.
131
+ function planFeatures(plan: Plan): ReadonlyArray<{ key: string; name: string }> {
132
+ return (plan as Plan & { features?: ReadonlyArray<{ key: string; name: string }> }).features ?? [];
133
+ }
134
+
129
135
  function minAmount(plan: Plan): number {
130
136
  const amounts = (plan.prices ?? []).map((p) => p.amount);
131
137
  return amounts.length > 0 ? Math.min(...amounts) : Number.POSITIVE_INFINITY;
@@ -351,6 +357,14 @@
351
357
  <p class="bridge-plan-description">{plan.description}</p>
352
358
  {/if}
353
359
 
360
+ {#if planFeatures(plan).length > 0}
361
+ <ul class="bridge-plan-features" data-bridge-plan-features aria-label={`Included in ${plan.name}`}>
362
+ {#each planFeatures(plan) as feature (feature.key)}
363
+ <li class="bridge-plan-feature" data-feature={feature.key}>{feature.name}</li>
364
+ {/each}
365
+ </ul>
366
+ {/if}
367
+
354
368
  <div class="bridge-plan-prices">
355
369
  {#each visiblePrices as price (price.recurrenceInterval + price.currency)}
356
370
  <button
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { BridgeQuotaRefusal } from '../core/quota-refusal.js';
3
- import type { BridgeConfig, BridgeUpgradeDialogProps } from '../shared/types/config.js';
3
+ import type { BridgeConfig, BridgeUpgradeDialogProps, PlanWithFeatures } from '../shared/types/config.js';
4
4
  /**
5
5
  * The dialog to mount for a `billing` config: the built-in one (`default`), the
6
6
  * app's own component, or none (`false`). Anything that is not `false` and not
@@ -14,3 +14,10 @@ export declare function resolveUpgradeDialog(billing: BridgeConfig['billing'] |
14
14
  * (default `/subscription`).
15
15
  */
16
16
  export declare function upgradeHrefFor(refusal: Pick<BridgeQuotaRefusal, 'fix'> | null, billing: BridgeConfig['billing'] | undefined): string;
17
+ /**
18
+ * TBP-755 — the names of the plans that include `feature`, cheapest first (the
19
+ * plan picker's order). Empty when there is no feature, no plan list, or no
20
+ * plan lists it. The upgrade dialog and the pricing table read the same list:
21
+ * each plan's `features`.
22
+ */
23
+ export declare function plansIncludingFeature(plans: ReadonlyArray<PlanWithFeatures> | null | undefined, feature: string | null | undefined): string[];
@@ -24,3 +24,21 @@ export function resolveUpgradeDialog(billing) {
24
24
  export function upgradeHrefFor(refusal, billing) {
25
25
  return refusal?.fix ?? resolveBillingRoutes(billing).manageRoute;
26
26
  }
27
+ /**
28
+ * TBP-755 — the names of the plans that include `feature`, cheapest first (the
29
+ * plan picker's order). Empty when there is no feature, no plan list, or no
30
+ * plan lists it. The upgrade dialog and the pricing table read the same list:
31
+ * each plan's `features`.
32
+ */
33
+ export function plansIncludingFeature(plans, feature) {
34
+ if (!feature || !plans)
35
+ return [];
36
+ const cheapest = (p) => {
37
+ const amounts = (p.prices ?? []).map((price) => price.amount);
38
+ return amounts.length > 0 ? Math.min(...amounts) : Number.POSITIVE_INFINITY;
39
+ };
40
+ return plans
41
+ .filter((p) => (p.features ?? []).some((f) => f.key === feature))
42
+ .sort((a, b) => cheapest(a) - cheapest(b))
43
+ .map((p) => p.name);
44
+ }
@@ -36,5 +36,10 @@ export declare function wrapFetchWithBridgeAuth(baseFetch: typeof fetch, apiBase
36
36
  * `@RequireQuota` sends at the plan's cap) opens the upgrade dialog that
37
37
  * `<BridgeBootstrap>` mounts, whatever origin your backend is on. The response
38
38
  * is still returned to you unchanged.
39
+ *
40
+ * TBP-697 — in development, if your backend says it counted a metric (the
41
+ * `X-Bridge-Usage-Counted` header bridge-nestjs sends outside production) and
42
+ * this page also reports that metric with `bridge.usage`, the console warns
43
+ * once: count once, where the action happens.
39
44
  */
40
45
  export declare function bridgeFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -1,6 +1,7 @@
1
1
  import { getBridgeAuth } from './bridge-instance.js';
2
2
  import { observeQuotaRefusal, watchesQuotaOrigin } from './quota-refusal.js';
3
3
  import { getConfig } from '../client/stores/config.store.js';
4
+ import { noteBackendResponse } from './double-count-warning.js';
4
5
  function requestUrl(input) {
5
6
  return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
6
7
  }
@@ -64,6 +65,7 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
64
65
  if (!url.startsWith(apiBaseUrl)) {
65
66
  const passthrough = await baseFetch(input, init);
66
67
  observeIfWatched(passthrough, url, apiBaseUrl);
68
+ noteBackendResponse(passthrough); // TBP-697 — dev-only double-count check
67
69
  return passthrough;
68
70
  }
69
71
  // 1. Inject current access token.
@@ -119,10 +121,16 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
119
121
  * `@RequireQuota` sends at the plan's cap) opens the upgrade dialog that
120
122
  * `<BridgeBootstrap>` mounts, whatever origin your backend is on. The response
121
123
  * is still returned to you unchanged.
124
+ *
125
+ * TBP-697 — in development, if your backend says it counted a metric (the
126
+ * `X-Bridge-Usage-Counted` header bridge-nestjs sends outside production) and
127
+ * this page also reports that metric with `bridge.usage`, the console warns
128
+ * once: count once, where the action happens.
122
129
  */
123
130
  export async function bridgeFetch(input, init) {
124
131
  const response = await fetchWithToken(input, init);
125
132
  void observeQuotaRefusal(response, absoluteUrl(requestUrl(input)));
133
+ noteBackendResponse(response);
126
134
  return response;
127
135
  }
128
136
  async function fetchWithToken(input, init) {
@@ -176,8 +176,8 @@ export function startBridgeRuntime(options = {}) {
176
176
  // of parking. A signed-out session has nothing to refresh. Loop safety:
177
177
  // the refreshed token lands in the tokenStore subscription below, whose
178
178
  // reauthorize() is a no-op while that episode is still connecting, and
179
- // the reconnect it produces is flagged self-induced so setOnOpen does not
180
- // refresh a second time.
179
+ // the reconcile after the reconnect (TBP-700) mints once more and stops
180
+ // there — a token that changes nothing does not replace the socket.
181
181
  refreshAuthToken: options.realtime?.refreshAuthToken ??
182
182
  (async () => {
183
183
  if (!_currentAuthToken)
@@ -191,11 +191,10 @@ export function startBridgeRuntime(options = {}) {
191
191
  }
192
192
  }),
193
193
  });
194
- let _connectedOnce = false;
195
- // Set just before we call _realtime.reauthorize() so the resulting
196
- // reconnect's setOnOpen handler knows the token is already fresh and skips
197
- // its proactive refresh — see the loop note in setOnOpen below.
198
- let _reauthInFlight = false;
194
+ // TBP-700 — bookkeeping for reconcileUserState() below.
195
+ let _reconciling = 0;
196
+ let _reconcileSawChange = false;
197
+ let _reconcileSwapsInARow = 0;
199
198
  // TBP-660 — catch up after ANY reconnect, including the one our own
200
199
  // reauthorize() causes. AppSync Events has no replay, so a push published
201
200
  // while the socket is being replaced is gone for good — and a plan change
@@ -240,11 +239,12 @@ export function startBridgeRuntime(options = {}) {
240
239
  return;
241
240
  const data = (await res.json());
242
241
  // Stopped, or the session changed while this was in flight: the answer
243
- // describes a session we no longer have. A token change reauthorizes,
244
- // and that reconnect catches up again with the right token.
245
- if (_realtime !== rt || _currentAuthToken !== token)
242
+ // describes a session we no longer have. A new token for the SAME user,
243
+ // workspace and app is not a new session — the reconcile below rotates
244
+ // the token on every open, concurrently with this read (TBP-700).
245
+ if (_realtime !== rt || !sameSession(token, _currentAuthToken))
246
246
  return;
247
- const { planChanged, entitlementsChanged } = applyCatchUpSnapshot(data);
247
+ const { planChanged, entitlementsChanged, initial } = applyCatchUpSnapshot(data);
248
248
  if (entitlementsChanged && data?.tenant?.entitlements) {
249
249
  // Keep auth-core's copy (what flag targeting reads) in step too.
250
250
  try {
@@ -254,6 +254,14 @@ export function startBridgeRuntime(options = {}) {
254
254
  }
255
255
  // TBP-654 — a recovered change must reach the route guard exactly like
256
256
  // the push it replaces would have. Nothing changed → nothing to redo.
257
+ //
258
+ // TBP-700 — and filling empty stores for the first time recovers no
259
+ // change: nothing was decided against a plan this page never knew. It
260
+ // used to count as a plan change, which refreshed the token and swapped
261
+ // the socket right after every first connect — the window a role change
262
+ // was lost in on stage. The access token is reconciled below instead.
263
+ if (initial)
264
+ return;
257
265
  if (planChanged)
258
266
  authorizationChanged('subscription.plan_changed');
259
267
  else if (entitlementsChanged)
@@ -335,7 +343,7 @@ export function startBridgeRuntime(options = {}) {
335
343
  const data = (await res.json());
336
344
  // Stopped, or the session changed while this was in flight: the
337
345
  // answer describes a workspace we no longer have.
338
- if (_realtime !== rt || _currentAuthToken !== token)
346
+ if (_realtime !== rt || !sameSession(token, _currentAuthToken))
339
347
  return;
340
348
  if (quotas.getAll().get(metric) !== before)
341
349
  return; // a push won the race
@@ -348,6 +356,57 @@ export function startBridgeRuntime(options = {}) {
348
356
  }
349
357
  }));
350
358
  };
359
+ // TBP-700 — the same repair for the user's own state: role, privileges,
360
+ // anything that bumps the server's tokenVersion and publishes
361
+ // `user.state_changed`. That push is lost exactly like the others when it
362
+ // is published while the socket is being replaced, and before this nothing
363
+ // recovered it: the session snapshot's `user` is read from the token being
364
+ // presented, and a reconnect our own reauthorize() caused skipped the token
365
+ // refresh (the TBP-644 loop guard). On stage a role change landed in that
366
+ // window in 4 of 4 runs once the first connect started swapping its socket
367
+ // (TBP-686), and the user kept the old role until a reload.
368
+ //
369
+ // The server's current tokenVersion and claims are only available by
370
+ // minting: the refresh endpoint reads them from the database, uncached. So
371
+ // every connect and reconnect ends with ONE refresh, and the token that
372
+ // comes back is compared with the one we had:
373
+ // - same authority (only iat/exp/jti moved): nothing was missed, and the
374
+ // socket that was just subscribed stays — no swap, so no loop;
375
+ // - anything else (tv, role, plan…): that IS the missed change. It is
376
+ // handled like the push would have been — the route guard re-checks and
377
+ // the socket is re-authorized with the new token — and the reconnect
378
+ // reconciles again, which then finds nothing new.
379
+ //
380
+ // `fresh: true` (auth-core, TBP-700) keeps this from joining a
381
+ // refresh that started before the channels were live, whose token could
382
+ // predate the very change we are looking for. An older auth-core ignores
383
+ // the option and joins it — best effort, as before.
384
+ const reconcileUserState = async () => {
385
+ if (_realtime !== rt || !_currentAuthToken)
386
+ return; // signed out: no user state
387
+ let auth;
388
+ try {
389
+ auth = getBridgeAuth();
390
+ }
391
+ catch {
392
+ return; // BridgeAuth not constructed — nothing to refresh with.
393
+ }
394
+ _reconciling += 1;
395
+ _reconcileSawChange = false;
396
+ try {
397
+ const refresh = auth.refreshTokens;
398
+ await refresh.call(auth, { fresh: true });
399
+ }
400
+ catch {
401
+ // Best-effort: the TOKEN_VERSION_STALE retry in bridgeAuthFetch and the
402
+ // next reconnect are the fallbacks.
403
+ }
404
+ finally {
405
+ _reconciling -= 1;
406
+ if (!_reconcileSawChange)
407
+ _reconcileSwapsInARow = 0;
408
+ }
409
+ };
351
410
  const requestCatchUp = () => {
352
411
  if (!_currentAuthToken)
353
412
  return; // signed out: no tenant or user scope to fetch
@@ -356,9 +415,9 @@ export function startBridgeRuntime(options = {}) {
356
415
  return;
357
416
  }
358
417
  _catchUpInFlight = true;
359
- // Both repairs ride the one in-flight/queued gate, so an open storm still
360
- // costs one round of requests, and neither can starve the other.
361
- void Promise.all([catchUpSessionSnapshot(), catchUpQuotaSnapshots()]).catch(() => {
418
+ // Every repair rides the one in-flight/queued gate, so an open storm still
419
+ // costs one round of requests, and none can starve the others.
420
+ void Promise.all([catchUpSessionSnapshot(), catchUpQuotaSnapshots(), reconcileUserState()]).catch(() => {
362
421
  // Both halves are already best-effort internally; this is the backstop
363
422
  // that keeps a repair failure from surfacing as an unhandled rejection
364
423
  // in the consuming app. `.finally` below still drains the queue.
@@ -370,41 +429,29 @@ export function startBridgeRuntime(options = {}) {
370
429
  }
371
430
  });
372
431
  };
432
+ // TBP-700 — when to catch up. Everything above only works if it reads the
433
+ // server AFTER every channel is live: anything published before that is
434
+ // already in the database and the read sees it, anything after arrives on
435
+ // the socket. `open` fires on the FIRST accepted subscription, with the user
436
+ // channel possibly still pending, so an auth-core that can say "all
437
+ // subscribed" is asked; an older one falls back to `open`.
438
+ //
439
+ // One trigger for the initial connect, a genuine reconnect and the one our
440
+ // own reauthorize() causes alike: losing pushes is a property of the socket
441
+ // swap, whoever caused it (TBP-660), and the first connect is where the
442
+ // snapshot push always loses its race (TBP-686). `requestCatchUp` returns
443
+ // early when signed out and coalesces concurrent calls.
444
+ const realtimeWithSubscribed = _realtime;
445
+ const catchUpOnSubscribed = typeof realtimeWithSubscribed.setOnSubscribed === 'function';
446
+ if (catchUpOnSubscribed)
447
+ realtimeWithSubscribed.setOnSubscribed(() => requestCatchUp());
373
448
  _realtime.setOnOpen(() => {
374
449
  _setRealtimeStatus('open');
375
- // On reconnect (not initial connect), proactively refresh tokens.
376
- // If the WS was down when tokenVersion was bumped on the server, the
377
- // client missed the user.state_changed broadcast. Refreshing here
378
- // syncs tokens before the first post-reconnect request can fail with
379
- // TOKEN_VERSION_STALE.
380
- //
381
- // EXCEPT when this reconnect was caused by our OWN reauthorize() below
382
- // (a token-only refresh). In that case the token is already current, so
383
- // refreshing again would mint yet another JWT, which the tokenStore
384
- // subscription would see as a change and reauthorize() again → reconnect
385
- // → setOnOpen → refresh → … an unbounded loop that hammers /auth/token
386
- // (observed ~32 cycles/sec, jamming the page's main thread and stalling
387
- // every downstream wait). Only genuine, externally-triggered reconnects
388
- // (network blips, server restarts) should trigger the catch-up refresh.
389
- const causedByReauthorize = _reauthInFlight;
390
- _reauthInFlight = false;
391
- if (_connectedOnce && !causedByReauthorize) {
392
- getBridgeAuth().refreshTokens().catch(() => { });
393
- }
394
- // TBP-660 — the TOKEN refresh above is skipped for a self-induced
395
- // reconnect; the STATE catch-up is not. Losing pushes is a property of
396
- // the socket swap, whoever caused it, and the catch-up cannot loop.
397
- //
398
- // TBP-686 — and it runs on the FIRST connect too. It used to be gated on
399
- // `_connectedOnce`, which read as "only repair a reconnect" — but the
400
- // first connect is exactly where the snapshot goes missing: the server
401
- // publishes it fire-and-forget during authorize, before the subscription
402
- // is live, so it loses the race and there is nothing to replay. The result
403
- // was a signed-in page with a null workspace id, name and branding for the
404
- // whole session. `requestCatchUp` already returns early when signed out
405
- // and coalesces concurrent calls, so this costs one request per session.
406
- requestCatchUp();
407
- _connectedOnce = true;
450
+ // No token refresh here any more (TBP-700): the reconcile in
451
+ // requestCatchUp mints on EVERY open, the self-induced ones included, and
452
+ // cannot loop — see reconcileUserState.
453
+ if (!catchUpOnSubscribed)
454
+ requestCatchUp();
408
455
  for (const fn of _onOpenSubs) {
409
456
  try {
410
457
  fn();
@@ -437,10 +484,6 @@ export function startBridgeRuntime(options = {}) {
437
484
  // open/close/degraded mirrors above keep `realtimeStatus` working there).
438
485
  _realtime.setOnStatusChange?.((status) => {
439
486
  _setRealtimeStatusDetail(status);
440
- // A parked client never opens, so a reauthorize that ended in a refusal
441
- // must not leave the self-induced flag set for the next genuine reconnect.
442
- if (status.state === 'unauthorized')
443
- _reauthInFlight = false;
444
487
  for (const fn of _onStatusSubs) {
445
488
  try {
446
489
  fn(status);
@@ -560,10 +603,7 @@ export function startBridgeRuntime(options = {}) {
560
603
  // (none → A) and sign-out (A → none). Keying this on rotation only meant a
561
604
  // session that signed in after page load kept the anonymous connection —
562
605
  // or stayed parked after a refusal — until something else reconnected it.
563
- // Flagged self-induced so setOnOpen skips its catch-up refresh (see the
564
- // loop note there): the token we reconnect with is already current.
565
606
  const reauthorizeForTokenChange = () => {
566
- _reauthInFlight = true;
567
607
  void _realtime.reauthorize();
568
608
  };
569
609
  // `subscribe` emits the current value synchronously, before `start()` below.
@@ -572,7 +612,30 @@ export function startBridgeRuntime(options = {}) {
572
612
  _unsubscribeAuth = tokenStore.subscribe((tokens) => {
573
613
  const prevAuthToken = _currentAuthToken;
574
614
  _currentAuthToken = tokens?.accessToken ?? undefined;
575
- const tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
615
+ let tokenChanged = _tokenSubscriptionLive && prevAuthToken !== _currentAuthToken;
616
+ let reauthorize = tokenChanged;
617
+ // TBP-700 — the token minted by the post-(re)connect reconcile.
618
+ if (tokenChanged && _reconciling > 0) {
619
+ if (_realtime.getState?.() === 'open' && sameAuthorization(prevAuthToken, _currentAuthToken)) {
620
+ // Only its timing moved: nothing was missed, every verdict taken with
621
+ // the old token stands, and the socket that was just subscribed has
622
+ // exactly this authority. Replacing it would open the very window
623
+ // this repair exists for — and loop.
624
+ tokenChanged = false;
625
+ reauthorize = false;
626
+ }
627
+ else {
628
+ // The server moved on while we were not listening: the lost
629
+ // `user.state_changed`, recovered. Re-authorize like the push would
630
+ // have — bounded, so a claim that differs on every mint cannot turn
631
+ // this into a reconnect loop.
632
+ _reconcileSawChange = true;
633
+ if (_reconcileSwapsInARow >= MAX_RECONCILE_SWAPS)
634
+ reauthorize = false;
635
+ else
636
+ _reconcileSwapsInARow += 1;
637
+ }
638
+ }
576
639
  // TBP-654 + TBP-653 — a new token (sign-in, refresh after a plan change,
577
640
  // sign-out) invalidates every verdict taken with the old one, and the
578
641
  // current route is re-evaluated: a signed-out session on a protected page
@@ -616,7 +679,7 @@ export function startBridgeRuntime(options = {}) {
616
679
  // setUserId is a no-op when the user is unchanged (token-only refresh),
617
680
  // and a setter-driven reconnect waits out a backoff and cannot lift a
618
681
  // parked refusal — so reauthorize explicitly on every value change.
619
- if (tokenChanged)
682
+ if (reauthorize)
620
683
  reauthorizeForTokenChange();
621
684
  });
622
685
  _tokenSubscriptionLive = true;
@@ -739,6 +802,48 @@ export function __resetBridgeRuntime() {
739
802
  _realtime = undefined;
740
803
  }
741
804
  // ── helpers ─────────────────────────────────────────────────────────────────
805
+ /**
806
+ * TBP-700 — how many reconcile-driven socket replacements may follow one
807
+ * another before the reconcile stops replacing the socket (it still adopts the
808
+ * token). A genuine missed change costs one; a second is a change published
809
+ * during that swap. More than a few in a row means a claim differs on every
810
+ * mint, and replacing the socket forever would be the TBP-644 loop again.
811
+ */
812
+ const MAX_RECONCILE_SWAPS = 3;
813
+ /** Claims that differ on every mint without meaning anything changed. */
814
+ const PER_MINT_CLAIMS = new Set(['iat', 'exp', 'nbf', 'jti', 'auth_time']);
815
+ /** Same user, workspace and app — the scope a catch-up read belongs to. */
816
+ function sameSession(a, b) {
817
+ if (!a || !b)
818
+ return false;
819
+ if (a === b)
820
+ return true;
821
+ const ca = decodeJwtPayload(a);
822
+ const cb = decodeJwtPayload(b);
823
+ if (!ca || !cb)
824
+ return false;
825
+ return ca.sub === cb.sub && ca.tid === cb.tid && ca.aid === cb.aid;
826
+ }
827
+ /**
828
+ * TBP-700 — two tokens carry the same authority: every claim is equal except
829
+ * the per-mint ones. Unreadable tokens are never the same.
830
+ */
831
+ function sameAuthorization(a, b) {
832
+ if (!a || !b)
833
+ return false;
834
+ const ca = decodeJwtPayload(a);
835
+ const cb = decodeJwtPayload(b);
836
+ if (!ca || !cb)
837
+ return false;
838
+ const keys = new Set([...Object.keys(ca), ...Object.keys(cb)]);
839
+ for (const k of keys) {
840
+ if (PER_MINT_CLAIMS.has(k))
841
+ continue;
842
+ if (JSON.stringify(ca[k]) !== JSON.stringify(cb[k]))
843
+ return false;
844
+ }
845
+ return true;
846
+ }
742
847
  /** Decode a JWT payload without signature verification (client context only). */
743
848
  function decodeJwtPayload(token) {
744
849
  try {
@@ -56,12 +56,13 @@ export interface BridgeTenantSurface {
56
56
  /**
57
57
  * TBP-697 — usage reporting from the browser.
58
58
  *
59
- * **Self-reported: this is a trusted-client path.** Anything running in the
60
- * user's browser can call it with any value, so a frontend-only app cannot
61
- * *enforce* a quota with it — only a backend can refuse a write. Use it when the
62
- * app has no backend that sees the action: a local-first or mobile app whose
63
- * data lives on the device. When you do have a backend, report there instead
64
- * (bridge-nestjs `@RequireQuota`, `usage.report` on the server SDK).
59
+ * Count once, where the action happens. When the action stays in the browser
60
+ * (a local-first or mobile app, data on the device) count it here — a
61
+ * first-class setup that trusts the browser: Bridge shows and bills what the
62
+ * page reports, and only a backend can refuse a write. When the click calls
63
+ * your server, the backend handler counts it (bridge-nestjs `@RequireQuota`)
64
+ * and the page reports nothing. In development the console warns once when a
65
+ * metric is counted on both sides.
65
66
  *
66
67
  * Which call: *if deleting it frees room, it's a gauge and your app counts it
67
68
  * (`set`); if it happened, it's a counter and Bridge counts it (`report`).*
@@ -18,6 +18,7 @@
18
18
  import { derived, get } from 'svelte/store';
19
19
  import { appBrandingStore, tenantEntitlementsStore, tenantIdStore, tenantNameStore, tenantSubscriptionStore, userSnapshotStore, } from './snapshot-stores.js';
20
20
  import { LazySlice } from './lazy-slice.js';
21
+ import { noteBrowserCount } from './double-count-warning.js';
21
22
  import { DevAttributeProvider } from '@nebulr-group/bridge-auth-core';
22
23
  import { getBridgeAuth, tokenStore, subscriptionStore, loadSubscription } from './bridge-instance.js';
23
24
  import { bridgeEvents } from './events.js';
@@ -140,9 +141,11 @@ const _subscriptionSurface = {
140
141
  // BridgeAuth instance does not exist until bootstrap, and SSR imports this module.
141
142
  const _usage = {
142
143
  report(metric, value, idempotencyKey) {
144
+ noteBrowserCount(metric); // TBP-697 — dev warning when the backend counts it too
143
145
  getBridgeAuth().usage.report(metric, value, idempotencyKey);
144
146
  },
145
147
  async set(metric, value) {
148
+ noteBrowserCount(metric);
146
149
  const usage = getBridgeAuth().usage;
147
150
  // Peer range still admits auth-core 0.7.x, which has no gauges. Say so
148
151
  // instead of "set is not a function".
@@ -0,0 +1,8 @@
1
+ /** The response header bridge-nestjs sets, outside production, on a counting endpoint. */
2
+ export declare const USAGE_COUNTED_HEADER = "x-bridge-usage-counted";
3
+ /** `bridge.usage.report` / `set` was called for `metric` from this page. */
4
+ export declare function noteBrowserCount(metric: string): void;
5
+ /** A response from the app's own backend — records the metrics it says it counted. */
6
+ export declare function noteBackendResponse(response: Response | null | undefined): void;
7
+ /** Test hook: forget everything seen so far. */
8
+ export declare function __resetDoubleCountWarning(): void;
@@ -0,0 +1,74 @@
1
+ // TBP-697 — in development, warn when the browser and the backend both count
2
+ // the same metric.
3
+ //
4
+ // The rule: count once, where the action happens. When the click calls your
5
+ // server, the backend handler counts (bridge-nestjs `@RequireQuota` /
6
+ // `@SyncQuota`) and the page only shows the number. When there is no backend
7
+ // that sees the action, the browser counts (`bridge.usage.report` / `set`).
8
+ // Doing both counts every action twice, and nothing else would ever say so.
9
+ //
10
+ // How it is noticed: outside production, bridge-nestjs marks a response from a
11
+ // counting endpoint with `X-Bridge-Usage-Counted: <metric>[, <metric>]`. This
12
+ // module remembers the metrics the backend said it counts and the metrics this
13
+ // page reported through `bridge.usage`, and warns once per metric that appears
14
+ // in both. A development build only: in production nothing is recorded and
15
+ // nothing is printed (and the backend does not send the header there anyway).
16
+ //
17
+ // Scope is the page session, which is one signed-in workspace — both sides
18
+ // count for the workspace of the user's token.
19
+ /** The response header bridge-nestjs sets, outside production, on a counting endpoint. */
20
+ export const USAGE_COUNTED_HEADER = 'x-bridge-usage-counted';
21
+ const countedByBackend = new Set();
22
+ const countedByBrowser = new Set();
23
+ const warned = new Set();
24
+ function isDevBuild() {
25
+ try {
26
+ return import.meta.env.DEV === true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function warnIfBoth(metric) {
33
+ if (warned.has(metric) || !countedByBackend.has(metric) || !countedByBrowser.has(metric))
34
+ return;
35
+ warned.add(metric);
36
+ console.warn(`[bridge] '${metric}' is counted twice: your backend counts it (bridge-nestjs @RequireQuota / @SyncQuota) ` +
37
+ `and this page also reports it with bridge.usage. Count once, where the action happens: ` +
38
+ `when the click calls your server, keep the backend count and remove the bridge.usage call. ` +
39
+ `(Development only — this warning is not shown in production.)`);
40
+ }
41
+ /** `bridge.usage.report` / `set` was called for `metric` from this page. */
42
+ export function noteBrowserCount(metric) {
43
+ if (!isDevBuild() || typeof metric !== 'string' || metric === '')
44
+ return;
45
+ countedByBrowser.add(metric);
46
+ warnIfBoth(metric);
47
+ }
48
+ /** A response from the app's own backend — records the metrics it says it counted. */
49
+ export function noteBackendResponse(response) {
50
+ if (!isDevBuild())
51
+ return;
52
+ let value;
53
+ try {
54
+ value = response?.headers?.get(USAGE_COUNTED_HEADER);
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ if (!value)
60
+ return;
61
+ for (const raw of value.split(',')) {
62
+ const metric = raw.trim();
63
+ if (!metric)
64
+ continue;
65
+ countedByBackend.add(metric);
66
+ warnIfBoth(metric);
67
+ }
68
+ }
69
+ /** Test hook: forget everything seen so far. */
70
+ export function __resetDoubleCountWarning() {
71
+ countedByBackend.clear();
72
+ countedByBrowser.clear();
73
+ warned.clear();
74
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * TBP-756 — "this feature is not on your plan", as an event the upgrade dialog
3
+ * listens to.
4
+ *
5
+ * Owner decision (2026-09-28): nothing opens by itself. The upgrade dialog's
6
+ * feature variant opens only when the person does something gated:
7
+ * - reaches a route whose feature flag is off because of the plan (the route
8
+ * guard), on first load after redirecting to the rule's `redirectTo`;
9
+ * - clicks the upgrade prompt a `<FeatureFlag>` shows (its `fallback`
10
+ * snippet's `openUpgrade`, or the opt-in `upgrade` prompt);
11
+ * - makes a request the backend refuses with `402 FEATURE_NOT_IN_PLAN`
12
+ * (bridge-nestjs's flag guards), like the `402 QUOTA_EXCEEDED` dialog.
13
+ * A hidden feature with no fallback opens nothing.
14
+ */
15
+ import { type Readable } from 'svelte/store';
16
+ /** Why a feature is off, as Bridge reports it. */
17
+ export type BridgeFeatureOffReason = 'plan' | 'permission' | 'off' | 'rule' | 'rollout';
18
+ /** A request to show the upgrade dialog for a feature the plan does not include. */
19
+ export interface BridgeFeatureUpgrade {
20
+ /** The feature flag that is off. */
21
+ flag: string | null;
22
+ /**
23
+ * The plan feature the flag's rule asks for (`bridge:billing.entitlement.<feature>`),
24
+ * when it names one. The dialog lists the plans that include it.
25
+ */
26
+ feature: string | null;
27
+ /** Where to upgrade, from a backend refusal's `fix` (a same-app path), else null. */
28
+ fix: string | null;
29
+ }
30
+ /** The feature upgrade the dialog is showing, or `null`. */
31
+ export declare const featureUpgrade: Readable<BridgeFeatureUpgrade | null>;
32
+ /**
33
+ * Open the upgrade dialog for a feature the plan does not include. Call it from
34
+ * a click; a page render must never call it (owner rule: nothing opens by itself).
35
+ */
36
+ export declare function openFeatureUpgrade(request?: {
37
+ flag?: string | null;
38
+ feature?: string | null;
39
+ fix?: string | null;
40
+ }): void;
41
+ /** Close the feature variant of the upgrade dialog. */
42
+ export declare function dismissFeatureUpgrade(): void;
43
+ /**
44
+ * The upgrade request in a `402 FEATURE_NOT_IN_PLAN` body (bridge-nestjs), or
45
+ * null when the body is something else.
46
+ */
47
+ export declare function parseFeatureRefusal(body: unknown): BridgeFeatureUpgrade | null;
48
+ /** Test-only: forget the current request. */
49
+ export declare function __resetFeatureUpgradeForTests(): void;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * TBP-756 — "this feature is not on your plan", as an event the upgrade dialog
3
+ * listens to.
4
+ *
5
+ * Owner decision (2026-09-28): nothing opens by itself. The upgrade dialog's
6
+ * feature variant opens only when the person does something gated:
7
+ * - reaches a route whose feature flag is off because of the plan (the route
8
+ * guard), on first load after redirecting to the rule's `redirectTo`;
9
+ * - clicks the upgrade prompt a `<FeatureFlag>` shows (its `fallback`
10
+ * snippet's `openUpgrade`, or the opt-in `upgrade` prompt);
11
+ * - makes a request the backend refuses with `402 FEATURE_NOT_IN_PLAN`
12
+ * (bridge-nestjs's flag guards), like the `402 QUOTA_EXCEEDED` dialog.
13
+ * A hidden feature with no fallback opens nothing.
14
+ */
15
+ import { readable } from 'svelte/store';
16
+ import { safeFixPath } from './quota-refusal.js';
17
+ let _current = null;
18
+ let _setCurrent = null;
19
+ /** The feature upgrade the dialog is showing, or `null`. */
20
+ export const featureUpgrade = readable(null, (set) => {
21
+ _setCurrent = set;
22
+ set(_current);
23
+ return () => {
24
+ _setCurrent = null;
25
+ };
26
+ });
27
+ /**
28
+ * Open the upgrade dialog for a feature the plan does not include. Call it from
29
+ * a click; a page render must never call it (owner rule: nothing opens by itself).
30
+ */
31
+ export function openFeatureUpgrade(request = {}) {
32
+ _current = {
33
+ flag: request.flag ?? null,
34
+ feature: request.feature ?? null,
35
+ fix: safeFixPath(request.fix),
36
+ };
37
+ _setCurrent?.(_current);
38
+ }
39
+ /** Close the feature variant of the upgrade dialog. */
40
+ export function dismissFeatureUpgrade() {
41
+ _current = null;
42
+ _setCurrent?.(null);
43
+ }
44
+ /**
45
+ * The upgrade request in a `402 FEATURE_NOT_IN_PLAN` body (bridge-nestjs), or
46
+ * null when the body is something else.
47
+ */
48
+ export function parseFeatureRefusal(body) {
49
+ if (typeof body !== 'object' || body === null)
50
+ return null;
51
+ const b = body;
52
+ if (b.code !== 'FEATURE_NOT_IN_PLAN')
53
+ return null;
54
+ return {
55
+ flag: typeof b.flag === 'string' && b.flag ? b.flag : null,
56
+ feature: typeof b.feature === 'string' && b.feature ? b.feature : null,
57
+ fix: safeFixPath(b.fix),
58
+ };
59
+ }
60
+ /** Test-only: forget the current request. */
61
+ export function __resetFeatureUpgradeForTests() {
62
+ dismissFeatureUpgrade();
63
+ }
@@ -22,6 +22,7 @@
22
22
  * The dialog is decoration. The refusal is the backend's; this only explains it.
23
23
  */
24
24
  import { readable } from 'svelte/store';
25
+ import { openFeatureUpgrade, parseFeatureRefusal } from './feature-upgrade.js';
25
26
  /**
26
27
  * A same-app path, or null. A backend's `fix` becomes a link the user clicks,
27
28
  * so an absolute URL, a protocol-relative `//host` or a `javascript:` value is
@@ -129,8 +130,15 @@ export function observeQuotaRefusal(response, url = '') {
129
130
  .json()
130
131
  .then((body) => {
131
132
  const refusal = parseQuotaRefusal(body, url || response.url);
132
- if (refusal)
133
+ if (refusal) {
133
134
  reportQuotaRefusal(refusal);
135
+ return;
136
+ }
137
+ // TBP-756 — a flag-gated endpoint refused because the plan does not
138
+ // include the feature: the same dialog, in its feature variant.
139
+ const feature = parseFeatureRefusal(body);
140
+ if (feature)
141
+ openFeatureUpgrade(feature);
134
142
  })
135
143
  .catch(() => {
136
144
  /* not JSON — not a quota refusal */
@@ -104,6 +104,13 @@ export declare function applyEntitlementsChanged(msg: {
104
104
  export declare function applyCatchUpSnapshot(data: SessionSnapshotData): {
105
105
  planChanged: boolean;
106
106
  entitlementsChanged: boolean;
107
+ /**
108
+ * TBP-700 — the tenant stores were empty: this filled them for the first
109
+ * time rather than changing anything the page had already seen. The
110
+ * `*Changed` flags still say whether the values moved, so a caller can seed
111
+ * other copies; a first fill is not a recovered change.
112
+ */
113
+ initial: boolean;
107
114
  };
108
115
  /** Test-only: reset every snapshot store to `null`. Vitest hook. */
109
116
  export declare function __resetSnapshotStores(): void;
@@ -104,6 +104,7 @@ export function applyCatchUpSnapshot(data) {
104
104
  const subAfter = get(_tenantSubscription);
105
105
  const entAfter = get(_tenantEntitlements);
106
106
  return {
107
+ initial: subBefore === null && entBefore === null,
107
108
  planChanged: (subBefore?.plan?.slug ?? null) !== (subAfter?.plan?.slug ?? null) ||
108
109
  (subBefore?.status ?? null) !== (subAfter?.status ?? null),
109
110
  entitlementsChanged: !sameFlags(entBefore, entAfter),
@@ -6,27 +6,56 @@
6
6
  - `fallback` — rendered when the flag is off or no rule matched
7
7
 
8
8
  Both snippets receive the Bridge-decided value so you can use it directly.
9
+ TBP-756 — `fallback` also receives why the feature is off, and a way to open
10
+ the upgrade dialog:
11
+ - `reason`: 'plan' (an upgrade alone would turn it on), 'permission' (this
12
+ person's role or privileges), 'off', 'rule', 'rollout', or undefined when
13
+ Bridge has not said (the flag is not loaded yet)
14
+ - `feature`: with 'plan', the plan feature the rule asks for
15
+ - `openUpgrade()`: opens the upgrade dialog for this feature. Call it from a
16
+ click; rendering a fallback never opens anything by itself.
17
+
18
+ `upgrade` (opt-in): with no `fallback`, a feature that is off because of the
19
+ plan renders a small "Upgrade to use this" prompt in its place; clicking it
20
+ opens the upgrade dialog. Off for any other reason: nothing, as before.
9
21
 
10
22
  Usage:
11
23
  <FeatureFlag key="new-dashboard" defaultValue={false}>
12
24
  {#snippet children()}<NewDashboard />{/snippet}
13
25
  </FeatureFlag>
14
26
 
15
- <FeatureFlag key="ui-theme" defaultValue="light-mode">
16
- {#snippet children(value)}<App theme={value} />{/snippet}
17
- {#snippet fallback(value)}<App theme={value} />{/snippet}
27
+ <FeatureFlag key="reports" defaultValue={false}>
28
+ {#snippet children()}<Reports />{/snippet}
29
+ {#snippet fallback(_value, { reason, openUpgrade })}
30
+ {#if reason === 'plan'}<button onclick={openUpgrade}>Upgrade for reports</button>{/if}
31
+ {/snippet}
32
+ </FeatureFlag>
33
+
34
+ <FeatureFlag key="reports" defaultValue={false} upgrade>
35
+ {#snippet children()}<Reports />{/snippet}
18
36
  </FeatureFlag>
19
37
  -->
38
+ <script lang="ts" module>
39
+ /** TBP-756 — what a `<FeatureFlag>` fallback learns about why the feature is off. */
40
+ export interface FeatureFlagOffInfo {
41
+ reason: 'plan' | 'permission' | 'off' | 'rule' | 'rollout' | undefined;
42
+ feature: string | undefined;
43
+ openUpgrade: () => void;
44
+ }
45
+ </script>
46
+
20
47
  <script lang="ts" generics="T = boolean">
21
48
  import type { Snippet } from 'svelte';
22
49
  import type { EvalContext } from '@nebulr-group/bridge-auth-core';
23
50
  import { evaluateFlag } from './registry.js';
24
51
  import { _flagVersionsRune } from './flag.svelte.js';
52
+ import { openFeatureUpgrade } from '../core/feature-upgrade.js';
25
53
 
26
54
  let {
27
55
  key,
28
56
  defaultValue,
29
57
  context,
58
+ upgrade = false,
30
59
  children,
31
60
  fallback,
32
61
  }: {
@@ -38,8 +67,13 @@
38
67
  * attributes win on key collision over Bridge-managed providers.
39
68
  */
40
69
  context?: Partial<EvalContext>;
70
+ /**
71
+ * TBP-756 — opt in to an inline "Upgrade to use this" prompt when the
72
+ * feature is off because of the plan and there is no `fallback`.
73
+ */
74
+ upgrade?: boolean;
41
75
  children?: Snippet<[T]>;
42
- fallback?: Snippet<[T]>;
76
+ fallback?: Snippet<[T, FeatureFlagOffInfo]>;
43
77
  } = $props();
44
78
 
45
79
  const result = $derived.by(() => {
@@ -48,10 +82,29 @@
48
82
  _flagVersionsRune().get(key);
49
83
  return evaluateFlag<T>(key, defaultValue, context);
50
84
  });
85
+
86
+ // An auth-core without TBP-756 returns no reason; the info is then empty.
87
+ const off = $derived.by((): FeatureFlagOffInfo => {
88
+ const r = result as { reason?: FeatureFlagOffInfo['reason']; feature?: string };
89
+ return {
90
+ reason: r.reason,
91
+ feature: r.feature,
92
+ openUpgrade: () => openFeatureUpgrade({ flag: key, feature: r.feature ?? null }),
93
+ };
94
+ });
51
95
  </script>
52
96
 
53
97
  {#if result.passed}
54
98
  {#if children}{@render children(result.value)}{/if}
55
99
  {:else if fallback}
56
- {@render fallback(result.value)}
100
+ {@render fallback(result.value, off)}
101
+ {:else if upgrade && off.reason === 'plan'}
102
+ <button
103
+ type="button"
104
+ class="bridge-btn bridge-btn-secondary bridge-feature-upgrade"
105
+ data-bridge-feature-upgrade={key}
106
+ onclick={off.openUpgrade}
107
+ >
108
+ Upgrade to use this
109
+ </button>
57
110
  {/if}
@@ -1,3 +1,9 @@
1
+ /** TBP-756 — what a `<FeatureFlag>` fallback learns about why the feature is off. */
2
+ export interface FeatureFlagOffInfo {
3
+ reason: 'plan' | 'permission' | 'off' | 'rule' | 'rollout' | undefined;
4
+ feature: string | undefined;
5
+ openUpgrade: () => void;
6
+ }
1
7
  import type { Snippet } from 'svelte';
2
8
  import type { EvalContext } from '@nebulr-group/bridge-auth-core';
3
9
  declare function $$render<T = boolean>(): {
@@ -10,8 +16,13 @@ declare function $$render<T = boolean>(): {
10
16
  * attributes win on key collision over Bridge-managed providers.
11
17
  */
12
18
  context?: Partial<EvalContext>;
19
+ /**
20
+ * TBP-756 — opt in to an inline "Upgrade to use this" prompt when the
21
+ * feature is off because of the plan and there is no `fallback`.
22
+ */
23
+ upgrade?: boolean;
13
24
  children?: Snippet<[T]>;
14
- fallback?: Snippet<[T]>;
25
+ fallback?: Snippet<[T, FeatureFlagOffInfo]>;
15
26
  };
16
27
  exports: {};
17
28
  bindings: "";
@@ -2,6 +2,8 @@ export { createBridgeFlags, BrowserIdentityStorage, type CreateBridgeFlagsConfig
2
2
  export { evaluateFlag, setBridgeFlagsInstance, getBridgeFlagsInstance, notifyFlagChanged, notifyAllFlagsChanged, subscribeToFlagChanges, } from './registry.js';
3
3
  export { useFlag, flagStore, _flagVersionsRune, type FlagStore } from './flag.svelte.js';
4
4
  export { default as FeatureFlag } from './FeatureFlag.svelte';
5
+ export type { FeatureFlagOffInfo } from './FeatureFlag.svelte';
6
+ export { openFeatureUpgrade } from '../core/feature-upgrade.js';
5
7
  export { realtimeStatus, realtimeStatusDetail } from './realtime-status.js';
6
8
  export { onBridgeRealtimeStatus } from '../core/bridge-runtime.js';
7
9
  export type { ConnectionState, RealtimeStatus } from '@nebulr-group/bridge-auth-core';
@@ -12,6 +12,8 @@ export { evaluateFlag, setBridgeFlagsInstance, getBridgeFlagsInstance, notifyFla
12
12
  export { useFlag, flagStore, _flagVersionsRune } from './flag.svelte.js';
13
13
  // Component
14
14
  export { default as FeatureFlag } from './FeatureFlag.svelte';
15
+ // TBP-756 — open the upgrade dialog for a feature the plan does not include.
16
+ export { openFeatureUpgrade } from '../core/feature-upgrade.js';
15
17
  // Reactive realtime connection status (subscribe in components to show
16
18
  // offline indicators, retry banners, etc.).
17
19
  export { realtimeStatus, realtimeStatusDetail } from './realtime-status.js';
package/dist/index.d.ts CHANGED
@@ -63,6 +63,8 @@ export { default as QuotaGate } from './client/components/subscription/QuotaGate
63
63
  export { default as Entitled } from './client/components/subscription/Entitled.svelte';
64
64
  export { default as BridgeUpgradeDialog } from './client/components/subscription/BridgeUpgradeDialog.svelte';
65
65
  export { onBridgeQuotaExceeded, parseQuotaRefusal } from './core/quota-refusal.js';
66
+ export { openFeatureUpgrade, dismissFeatureUpgrade, featureUpgrade, parseFeatureRefusal, } from './core/feature-upgrade.js';
67
+ export type { BridgeFeatureUpgrade, BridgeFeatureOffReason } from './core/feature-upgrade.js';
66
68
  export type { BridgeQuotaRefusal } from './core/quota-refusal.js';
67
69
  export * from './auth/route-guard.js';
68
70
  export * from './shared/profile.js';
package/dist/index.js CHANGED
@@ -102,6 +102,9 @@ export { default as QuotaGate } from './client/components/subscription/QuotaGate
102
102
  export { default as Entitled } from './client/components/subscription/Entitled.svelte';
103
103
  export { default as BridgeUpgradeDialog } from './client/components/subscription/BridgeUpgradeDialog.svelte';
104
104
  export { onBridgeQuotaExceeded, parseQuotaRefusal } from './core/quota-refusal.js';
105
+ // TBP-756 — the upgrade dialog's feature variant: a plan-gated route, a
106
+ // <FeatureFlag> upgrade click, or a backend's 402 FEATURE_NOT_IN_PLAN.
107
+ export { openFeatureUpgrade, dismissFeatureUpgrade, featureUpgrade, parseFeatureRefusal, } from './core/feature-upgrade.js';
105
108
  // Auth route guards
106
109
  export * from './auth/route-guard.js';
107
110
  // Types
@@ -72,4 +72,28 @@ export interface BridgeUpgradeDialogProps {
72
72
  canUpgrade: boolean;
73
73
  /** Close the dialog. */
74
74
  onclose: () => void;
75
+ /** TBP-755/756 — the plan feature the user is missing, by key (or the
76
+ * feature flag's key when its rule names no plan feature). With no
77
+ * `refusal`, a non-null `feature` opens the dialog in its feature variant;
78
+ * when `plans` lists plans that include it, the dialog names them.
79
+ * BridgeBootstrap sets it only after the person did something gated: a
80
+ * plan-gated route, a `<FeatureFlag>` upgrade click, or a backend's
81
+ * `402 FEATURE_NOT_IN_PLAN`. */
82
+ feature?: string | null;
83
+ /** TBP-755 — the app's plans (the plan picker's feed), each with the
84
+ * features it includes. Used only to name the plans that include `feature`. */
85
+ plans?: ReadonlyArray<PlanWithFeatures> | null;
86
+ }
87
+ /** TBP-755 — a plan as the plan list returns it, with the features it
88
+ * includes. Structural so it holds whichever auth-core release is installed. */
89
+ export interface PlanWithFeatures {
90
+ key: string;
91
+ name: string;
92
+ prices?: ReadonlyArray<{
93
+ amount: number;
94
+ }>;
95
+ features?: ReadonlyArray<{
96
+ key: string;
97
+ name: string;
98
+ }>;
75
99
  }
package/dist/styles.css CHANGED
@@ -1177,6 +1177,16 @@
1177
1177
  font-size: 0.875rem;
1178
1178
  }
1179
1179
 
1180
+ /* TBP-755 — the features a plan includes. */
1181
+ .bridge-plan-features {
1182
+ margin: 0;
1183
+ padding-left: 1.125rem;
1184
+ font-size: 0.875rem;
1185
+ display: flex;
1186
+ flex-direction: column;
1187
+ gap: 0.25rem;
1188
+ }
1189
+
1180
1190
  .bridge-plan-prices {
1181
1191
  display: flex;
1182
1192
  flex-direction: column;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.9.0-beta.4",
3
+ "version": "0.9.0-beta.6",
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",
@@ -79,7 +79,7 @@
79
79
  "typescript": "^6.0.0",
80
80
  "vite": "^6.2.6",
81
81
  "vitest": "^4.1.4",
82
- "@nebulr-group/bridge-auth-core": "0.8.0-beta.0"
82
+ "@nebulr-group/bridge-auth-core": "0.8.0-beta.3"
83
83
  },
84
84
  "keywords": [
85
85
  "svelte",