@nebulr-group/bridge-svelte 0.9.0-beta.5 → 0.9.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/route-guard.d.ts +8 -0
- package/dist/auth/route-guard.js +19 -7
- package/dist/client/BridgeBootstrap.js +21 -2
- package/dist/client/BridgeBootstrap.svelte +33 -3
- package/dist/client/components/subscription/BridgeUpgradeDialog.svelte +52 -5
- package/dist/client/components/subscription/PlanSelector.svelte +14 -0
- package/dist/client/upgrade-dialog.d.ts +8 -1
- package/dist/client/upgrade-dialog.js +18 -0
- package/dist/core/bridge-fetch.d.ts +5 -0
- package/dist/core/bridge-fetch.js +23 -3
- package/dist/core/bridge.d.ts +7 -6
- package/dist/core/bridge.js +3 -0
- package/dist/core/double-count-warning.d.ts +8 -0
- package/dist/core/double-count-warning.js +74 -0
- package/dist/core/feature-upgrade.d.ts +49 -0
- package/dist/core/feature-upgrade.js +63 -0
- package/dist/core/quota-refusal.js +9 -1
- package/dist/flags/FeatureFlag.svelte +58 -5
- package/dist/flags/FeatureFlag.svelte.d.ts +12 -1
- package/dist/flags/index.d.ts +2 -0
- package/dist/flags/index.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/shared/types/config.d.ts +24 -0
- package/dist/styles.css +10 -0
- package/package.json +3 -3
|
@@ -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
|
};
|
package/dist/auth/route-guard.js
CHANGED
|
@@ -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
|
|
38
|
+
const restriction = await readRestriction(pathname);
|
|
39
39
|
if (generation === guardCacheGeneration())
|
|
40
|
-
return
|
|
40
|
+
return restriction;
|
|
41
41
|
dropFlagCache();
|
|
42
42
|
if (attempt >= MAX_FRESH_READS)
|
|
43
|
-
return
|
|
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
|
|
128
|
-
if (
|
|
129
|
-
|
|
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'
|
|
435
|
-
|
|
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
|
-
|
|
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={
|
|
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 (
|
|
26
|
-
else if (!
|
|
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 (
|
|
55
|
+
if (isOpen) onclose();
|
|
40
56
|
}}
|
|
41
57
|
>
|
|
42
|
-
{#if
|
|
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.
|
|
@@ -87,9 +89,21 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
|
|
|
87
89
|
.extensions?.response?.code === 'TOKEN_VERSION_STALE');
|
|
88
90
|
if (!isStale)
|
|
89
91
|
return response;
|
|
90
|
-
// 3. Refresh
|
|
91
|
-
//
|
|
92
|
-
|
|
92
|
+
// 3. Refresh with a token minted AFTER this answer. The server just said
|
|
93
|
+
// our tokenVersion is behind, so a refresh that was already in flight
|
|
94
|
+
// (the per-connect reconcile, the WebSocket user.state_changed path)
|
|
95
|
+
// may have been minted before the bump and come back just as stale —
|
|
96
|
+
// retrying with it fails the same way and the caller sees "access
|
|
97
|
+
// token has been invalidated; refresh required". `fresh` waits for
|
|
98
|
+
// such a refresh and mints again (or joins one that started after
|
|
99
|
+
// this call), so the retry carries the current version.
|
|
100
|
+
// Stage, 2026-09-28: a new user's first CreateApp failed twice this way
|
|
101
|
+
// (retry sent tv 0 while the server was at tv 1). TBP-747.
|
|
102
|
+
// An auth-core older than 0.8.0-beta.2 (the peer range allows 0.7.x)
|
|
103
|
+
// ignores the option and joins the in-flight refresh, as before.
|
|
104
|
+
const auth = getBridgeAuth();
|
|
105
|
+
const refresh = auth.refreshTokens;
|
|
106
|
+
await refresh.call(auth, { fresh: true }).catch(() => { });
|
|
93
107
|
const freshToken = getBridgeAuth().getTokens()?.accessToken;
|
|
94
108
|
const freshHeaders = new Headers(init?.headers);
|
|
95
109
|
if (freshToken)
|
|
@@ -119,10 +133,16 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
|
|
|
119
133
|
* `@RequireQuota` sends at the plan's cap) opens the upgrade dialog that
|
|
120
134
|
* `<BridgeBootstrap>` mounts, whatever origin your backend is on. The response
|
|
121
135
|
* is still returned to you unchanged.
|
|
136
|
+
*
|
|
137
|
+
* TBP-697 — in development, if your backend says it counted a metric (the
|
|
138
|
+
* `X-Bridge-Usage-Counted` header bridge-nestjs sends outside production) and
|
|
139
|
+
* this page also reports that metric with `bridge.usage`, the console warns
|
|
140
|
+
* once: count once, where the action happens.
|
|
122
141
|
*/
|
|
123
142
|
export async function bridgeFetch(input, init) {
|
|
124
143
|
const response = await fetchWithToken(input, init);
|
|
125
144
|
void observeQuotaRefusal(response, absoluteUrl(requestUrl(input)));
|
|
145
|
+
noteBackendResponse(response);
|
|
126
146
|
return response;
|
|
127
147
|
}
|
|
128
148
|
async function fetchWithToken(input, init) {
|
package/dist/core/bridge.d.ts
CHANGED
|
@@ -56,12 +56,13 @@ export interface BridgeTenantSurface {
|
|
|
56
56
|
/**
|
|
57
57
|
* TBP-697 — usage reporting from the browser.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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`).*
|
package/dist/core/bridge.js
CHANGED
|
@@ -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 */
|
|
@@ -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="
|
|
16
|
-
{#snippet children(
|
|
17
|
-
{#snippet fallback(
|
|
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: "";
|
package/dist/flags/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/flags/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.9.0-beta.7",
|
|
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",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"svelte": "^5.0.0",
|
|
59
59
|
"@sveltejs/kit": "^2.0.0",
|
|
60
60
|
"@stripe/stripe-js": ">=4.0.0",
|
|
61
|
-
"@nebulr-group/bridge-auth-core": ">=0.
|
|
61
|
+
"@nebulr-group/bridge-auth-core": ">=0.8.0-beta.4 <1.0.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
64
|
"@stripe/stripe-js": {
|
|
@@ -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.
|
|
82
|
+
"@nebulr-group/bridge-auth-core": "0.8.0-beta.4"
|
|
83
83
|
},
|
|
84
84
|
"keywords": [
|
|
85
85
|
"svelte",
|