@nebulr-group/bridge-svelte 0.8.0-beta.0 → 0.8.0-beta.1
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.
|
@@ -45,7 +45,7 @@ import { RealtimeClient, useBridge, } from '@nebulr-group/bridge-auth-core';
|
|
|
45
45
|
import { getConfig } from '../client/stores/config.store.js';
|
|
46
46
|
import { getBridgeAuth, tokenStore } from './bridge-instance.js';
|
|
47
47
|
import { wrapFetchWithBridgeAuth } from './bridge-fetch.js';
|
|
48
|
-
import { applySessionSnapshot } from './snapshot-stores.js';
|
|
48
|
+
import { applyEntitlementsChanged, applySessionSnapshot, applySubscriptionPlanChanged, } from './snapshot-stores.js';
|
|
49
49
|
import { bridgeEvents } from './events.js';
|
|
50
50
|
import { _setRealtimeStatus, _setRealtimeStatusDetail } from './realtime-status.js';
|
|
51
51
|
let _realtime;
|
|
@@ -247,8 +247,21 @@ export function startBridgeRuntime(options = {}) {
|
|
|
247
247
|
useBridge().attachToRealtimeClient(_realtime);
|
|
248
248
|
// Phase 5 (TBP-331) + TBP-360 — billing-family events flow through the
|
|
249
249
|
// unified bridge events surface via `useBridge().handle({...})`.
|
|
250
|
+
//
|
|
251
|
+
// TBP-644 — the two pushes that carry the complete new value also move the
|
|
252
|
+
// `bridge.tenant.*` stores, which were otherwise written only by
|
|
253
|
+
// `session.snapshot`. A plan change never re-sends a snapshot, so without
|
|
254
|
+
// this an upgraded app kept rendering the old plan until a reload. The store
|
|
255
|
+
// is patched BEFORE dispatch so a `bridge.events` handler that reads
|
|
256
|
+
// `bridge.tenant.subscription` already sees the new plan.
|
|
250
257
|
useBridge().handle({
|
|
251
|
-
'subscription.plan_changed': (msg) =>
|
|
258
|
+
'subscription.plan_changed': (msg) => {
|
|
259
|
+
try {
|
|
260
|
+
applySubscriptionPlanChanged(msg);
|
|
261
|
+
}
|
|
262
|
+
catch { /* store updates shouldn't throw, defensive */ }
|
|
263
|
+
bridgeEvents._dispatch(msg);
|
|
264
|
+
},
|
|
252
265
|
'payment.failed': (msg) => bridgeEvents._dispatch(msg),
|
|
253
266
|
'payment.succeeded': (msg) => bridgeEvents._dispatch(msg),
|
|
254
267
|
'subscription.created': (msg) => bridgeEvents._dispatch(msg),
|
|
@@ -264,7 +277,14 @@ export function startBridgeRuntime(options = {}) {
|
|
|
264
277
|
'dunning.recovered': (msg) => bridgeEvents._dispatch(msg),
|
|
265
278
|
'dunning.exhausted': (msg) => bridgeEvents._dispatch(msg),
|
|
266
279
|
'quota.updated': (msg) => bridgeEvents._dispatch(msg),
|
|
267
|
-
'entitlements.changed': (msg) =>
|
|
280
|
+
'entitlements.changed': (msg) => {
|
|
281
|
+
// Only the payload-carrying variant has a map; the signal-only one is a no-op here.
|
|
282
|
+
try {
|
|
283
|
+
applyEntitlementsChanged(msg);
|
|
284
|
+
}
|
|
285
|
+
catch { /* defensive */ }
|
|
286
|
+
bridgeEvents._dispatch(msg);
|
|
287
|
+
},
|
|
268
288
|
});
|
|
269
289
|
// Token store subscription — owns realtime channel scoping + quotas HTTP
|
|
270
290
|
// options + reauthorize on token-only refresh. Capability-specific subs
|
|
@@ -59,8 +59,12 @@ vi.mock('./bridge-instance.js', () => ({
|
|
|
59
59
|
vi.mock('../client/stores/config.store.js', () => ({
|
|
60
60
|
getConfig: () => ({ appId: 'app-1', apiBaseUrl: 'http://test' }),
|
|
61
61
|
}));
|
|
62
|
+
// The billing-family handler table the runtime registers via useBridge().handle().
|
|
63
|
+
let _billingHandlers;
|
|
62
64
|
vi.mock('./snapshot-stores.js', () => ({
|
|
63
65
|
applySessionSnapshot: vi.fn(),
|
|
66
|
+
applySubscriptionPlanChanged: vi.fn(),
|
|
67
|
+
applyEntitlementsChanged: vi.fn(),
|
|
64
68
|
}));
|
|
65
69
|
vi.mock('./events.js', () => ({
|
|
66
70
|
bridgeEvents: { _dispatch: vi.fn() },
|
|
@@ -89,7 +93,10 @@ vi.mock('@nebulr-group/bridge-auth-core', () => {
|
|
|
89
93
|
useBridge: () => ({
|
|
90
94
|
quotas: { configure: vi.fn() },
|
|
91
95
|
attachToRealtimeClient: vi.fn(),
|
|
92
|
-
handle: vi.fn(() =>
|
|
96
|
+
handle: vi.fn((handlers) => {
|
|
97
|
+
_billingHandlers = handlers;
|
|
98
|
+
return () => { };
|
|
99
|
+
}),
|
|
93
100
|
}),
|
|
94
101
|
};
|
|
95
102
|
});
|
|
@@ -443,3 +450,58 @@ describe('full realtime status reaches the public API (TBP-644)', () => {
|
|
|
443
450
|
expect(handler).not.toHaveBeenCalled();
|
|
444
451
|
});
|
|
445
452
|
});
|
|
453
|
+
// Regression: `subscription.plan_changed` and `entitlements.changed` were only
|
|
454
|
+
// dispatched as events, so `bridge.tenant.*` kept the old plan / entitlements
|
|
455
|
+
// until a reload (TBP-644, reproduced end to end on stage 2026-09-14).
|
|
456
|
+
describe('billing pushes patch the bridge.tenant stores before dispatch (TBP-644)', () => {
|
|
457
|
+
const planChanged = {
|
|
458
|
+
kind: 'subscription.plan_changed',
|
|
459
|
+
tenantId: 'ws-1',
|
|
460
|
+
from: { slug: 'free' },
|
|
461
|
+
to: { slug: 'pro', name: 'Pro' },
|
|
462
|
+
status: 'active',
|
|
463
|
+
effectiveAt: '2026-09-14T15:56:31.654Z',
|
|
464
|
+
};
|
|
465
|
+
const entitlementsChanged = {
|
|
466
|
+
kind: 'entitlements.changed',
|
|
467
|
+
tenantId: 'ws-1',
|
|
468
|
+
effectiveAt: '2026-09-14T15:56:31.605Z',
|
|
469
|
+
entitlements: { app_active: true },
|
|
470
|
+
};
|
|
471
|
+
it('subscription.plan_changed patches the tenant subscription store, then dispatches', async () => {
|
|
472
|
+
const { bridgeEvents } = await import('./events.js');
|
|
473
|
+
const { applySubscriptionPlanChanged } = await import('./snapshot-stores.js');
|
|
474
|
+
const order = [];
|
|
475
|
+
vi.mocked(applySubscriptionPlanChanged).mockImplementationOnce(() => { order.push('store'); });
|
|
476
|
+
vi.mocked(bridgeEvents._dispatch).mockImplementationOnce(() => { order.push('dispatch'); });
|
|
477
|
+
_billingHandlers = undefined;
|
|
478
|
+
startBridgeRuntime();
|
|
479
|
+
_billingHandlers['subscription.plan_changed'](planChanged);
|
|
480
|
+
expect(applySubscriptionPlanChanged).toHaveBeenCalledWith(planChanged);
|
|
481
|
+
expect(bridgeEvents._dispatch).toHaveBeenCalledWith(planChanged);
|
|
482
|
+
expect(order).toEqual(['store', 'dispatch']);
|
|
483
|
+
});
|
|
484
|
+
it('entitlements.changed replaces the tenant entitlements store, then dispatches', async () => {
|
|
485
|
+
const { bridgeEvents } = await import('./events.js');
|
|
486
|
+
const { applyEntitlementsChanged } = await import('./snapshot-stores.js');
|
|
487
|
+
const order = [];
|
|
488
|
+
vi.mocked(applyEntitlementsChanged).mockImplementationOnce(() => { order.push('store'); });
|
|
489
|
+
vi.mocked(bridgeEvents._dispatch).mockImplementationOnce(() => { order.push('dispatch'); });
|
|
490
|
+
_billingHandlers = undefined;
|
|
491
|
+
startBridgeRuntime();
|
|
492
|
+
_billingHandlers['entitlements.changed'](entitlementsChanged);
|
|
493
|
+
expect(applyEntitlementsChanged).toHaveBeenCalledWith(entitlementsChanged);
|
|
494
|
+
expect(bridgeEvents._dispatch).toHaveBeenCalledWith(entitlementsChanged);
|
|
495
|
+
expect(order).toEqual(['store', 'dispatch']);
|
|
496
|
+
});
|
|
497
|
+
it('a failing store patch still dispatches the event to app handlers', async () => {
|
|
498
|
+
const { bridgeEvents } = await import('./events.js');
|
|
499
|
+
const { applySubscriptionPlanChanged } = await import('./snapshot-stores.js');
|
|
500
|
+
vi.mocked(applySubscriptionPlanChanged).mockImplementationOnce(() => { throw new Error('boom'); });
|
|
501
|
+
_billingHandlers = undefined;
|
|
502
|
+
startBridgeRuntime();
|
|
503
|
+
const msg = { ...planChanged, effectiveAt: 'throwing-case' };
|
|
504
|
+
expect(() => _billingHandlers['subscription.plan_changed'](msg)).not.toThrow();
|
|
505
|
+
expect(bridgeEvents._dispatch).toHaveBeenCalledWith(msg);
|
|
506
|
+
});
|
|
507
|
+
});
|
package/dist/core/bridge.d.ts
CHANGED
|
@@ -38,7 +38,8 @@ export interface BridgeTenantSurface {
|
|
|
38
38
|
name: Readable<string | null>;
|
|
39
39
|
/**
|
|
40
40
|
* Canonical subscription (plan + status + endsAt). Live via `session.snapshot`
|
|
41
|
-
* when the realtime channel is
|
|
41
|
+
* and every `subscription.plan_changed` push when the realtime channel is
|
|
42
|
+
* active; otherwise transparently falls back to
|
|
42
43
|
* the REST subscription endpoint (fetched lazily on first read).
|
|
43
44
|
*/
|
|
44
45
|
subscription: Readable<SubscriptionSnapshot | null>;
|
package/dist/core/bridge.test.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// previous values wholesale.
|
|
14
14
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
15
15
|
import { get } from 'svelte/store';
|
|
16
|
-
import { applySessionSnapshot, __resetSnapshotStores, } from './snapshot-stores.js';
|
|
16
|
+
import { applyEntitlementsChanged, applySessionSnapshot, applySubscriptionPlanChanged, __resetSnapshotStores, } from './snapshot-stores.js';
|
|
17
17
|
import { bridge, _allSnapshotSlices, __resetBridgeLazySlices } from './bridge.js';
|
|
18
18
|
const fullSnapshot = {
|
|
19
19
|
app: {
|
|
@@ -230,3 +230,79 @@ describe('bridge.app.plans lazy slice (Phase 4, TBP-321/322)', () => {
|
|
|
230
230
|
expect(get(bridge.app.plans)).toEqual([{ key: 'enterprise', name: 'Enterprise', prices: [] }]);
|
|
231
231
|
});
|
|
232
232
|
});
|
|
233
|
+
// Regression: a workspace upgrade reached the page as `subscription.plan_changed`
|
|
234
|
+
// but `bridge.tenant.subscription` kept the old plan until a reload, because
|
|
235
|
+
// only `session.snapshot` wrote it and a plan change never re-sends one
|
|
236
|
+
// (TBP-644, reproduced end to end on stage 2026-09-14).
|
|
237
|
+
describe('live pushes move bridge.tenant.* without a new snapshot (TBP-644)', () => {
|
|
238
|
+
const planChanged = (to, status = 'active') => ({
|
|
239
|
+
kind: 'subscription.plan_changed',
|
|
240
|
+
tenantId: 'ws-1',
|
|
241
|
+
from: { slug: 'free' },
|
|
242
|
+
to,
|
|
243
|
+
status,
|
|
244
|
+
effectiveAt: '2026-09-14T15:56:31.654Z',
|
|
245
|
+
});
|
|
246
|
+
function snapshotOnFree(extra = {}) {
|
|
247
|
+
applySessionSnapshot({
|
|
248
|
+
...fullSnapshot,
|
|
249
|
+
tenant: {
|
|
250
|
+
...fullSnapshot.tenant,
|
|
251
|
+
subscription: { plan: { slug: 'free', name: 'Free' }, status: 'active', ...extra },
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
it('subscription.plan_changed replaces plan + status on bridge.tenant.subscription', () => {
|
|
256
|
+
snapshotOnFree();
|
|
257
|
+
applySubscriptionPlanChanged(planChanged({ slug: 'pro', name: 'Pro' }));
|
|
258
|
+
expect(get(bridge.tenant.subscription)).toEqual({ plan: { slug: 'pro', name: 'Pro' }, status: 'active' });
|
|
259
|
+
});
|
|
260
|
+
it('a subscriber sees free → pro live, with no further snapshot', () => {
|
|
261
|
+
snapshotOnFree();
|
|
262
|
+
const seen = [];
|
|
263
|
+
const unsub = bridge.tenant.subscription.subscribe((s) => seen.push(s?.plan.slug));
|
|
264
|
+
applySubscriptionPlanChanged(planChanged({ slug: 'pro', name: 'Pro' }));
|
|
265
|
+
unsub();
|
|
266
|
+
expect(seen).toEqual(['free', 'pro']);
|
|
267
|
+
});
|
|
268
|
+
it('keeps the fields the push does not carry (endsAt, gateEngaged)', () => {
|
|
269
|
+
snapshotOnFree({ endsAt: '2026-10-01T00:00:00.000Z', gateEngaged: false });
|
|
270
|
+
applySubscriptionPlanChanged(planChanged({ slug: 'pro', name: 'Pro' }));
|
|
271
|
+
expect(get(bridge.tenant.subscription)).toEqual({
|
|
272
|
+
plan: { slug: 'pro', name: 'Pro' },
|
|
273
|
+
status: 'active',
|
|
274
|
+
endsAt: '2026-10-01T00:00:00.000Z',
|
|
275
|
+
gateEngaged: false,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
it('the pushed status wins over the snapshot status', () => {
|
|
279
|
+
snapshotOnFree({ status: 'trialing' });
|
|
280
|
+
applySubscriptionPlanChanged(planChanged({ slug: 'pro', name: 'Pro' }, 'active'));
|
|
281
|
+
expect(get(bridge.tenant.subscription)?.status).toBe('active');
|
|
282
|
+
});
|
|
283
|
+
it('works when no snapshot ever landed (the push alone populates the slice)', () => {
|
|
284
|
+
applySubscriptionPlanChanged(planChanged({ slug: 'pro', name: 'Pro' }));
|
|
285
|
+
expect(get(bridge.tenant.subscription)).toEqual({ plan: { slug: 'pro', name: 'Pro' }, status: 'active' });
|
|
286
|
+
});
|
|
287
|
+
it('ignores a push without a plan slug and never throws', () => {
|
|
288
|
+
snapshotOnFree();
|
|
289
|
+
expect(() => applySubscriptionPlanChanged(undefined)).not.toThrow();
|
|
290
|
+
expect(() => applySubscriptionPlanChanged({ to: null, status: 'active' })).not.toThrow();
|
|
291
|
+
expect(() => applySubscriptionPlanChanged({ to: { slug: '' } })).not.toThrow();
|
|
292
|
+
expect(get(bridge.tenant.subscription)?.plan.slug).toBe('free');
|
|
293
|
+
});
|
|
294
|
+
it('entitlements.changed with a map replaces the entitlements slice wholesale', () => {
|
|
295
|
+
applySessionSnapshot(fullSnapshot);
|
|
296
|
+
expect(bridge.tenant.entitlements.can('ai_completions')).toBe(true);
|
|
297
|
+
applyEntitlementsChanged({ entitlements: { app_active: true, projects: true } });
|
|
298
|
+
expect(get(bridge.tenant.entitlements.snapshot)).toEqual({ app_active: true, projects: true });
|
|
299
|
+
expect(bridge.tenant.entitlements.can('ai_completions')).toBe(false);
|
|
300
|
+
expect(bridge.tenant.entitlements.can('projects')).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
it('the signal-only entitlements.changed (no map) leaves the slice untouched', () => {
|
|
303
|
+
applySessionSnapshot(fullSnapshot);
|
|
304
|
+
applyEntitlementsChanged({});
|
|
305
|
+
applyEntitlementsChanged(undefined);
|
|
306
|
+
expect(get(bridge.tenant.entitlements.snapshot)).toEqual(fullSnapshot.tenant.entitlements);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -65,5 +65,34 @@ export interface SessionSnapshotData {
|
|
|
65
65
|
* with `null`.
|
|
66
66
|
*/
|
|
67
67
|
export declare function applySessionSnapshot(data: SessionSnapshotData): void;
|
|
68
|
+
/**
|
|
69
|
+
* TBP-644 — move `bridge.tenant.subscription` on a `subscription.plan_changed`
|
|
70
|
+
* push. Until now this store was written only by `session.snapshot`, and a plan
|
|
71
|
+
* change never re-sends one, so an upgraded workspace kept rendering its old
|
|
72
|
+
* plan until a reload — while auth-core's `useBridge().subscription`, hydrated
|
|
73
|
+
* from the very same push, already had the new one.
|
|
74
|
+
*
|
|
75
|
+
* The push is authoritative for plan and status (auth-core's contract for it:
|
|
76
|
+
* "consumers hydrate their cached state on receipt; no refetch required").
|
|
77
|
+
* Fields it does not carry (`endsAt`, `gateEngaged`) keep their current value.
|
|
78
|
+
* A payload without a plan slug is ignored. Never throws.
|
|
79
|
+
*/
|
|
80
|
+
export declare function applySubscriptionPlanChanged(msg: {
|
|
81
|
+
to?: {
|
|
82
|
+
slug?: unknown;
|
|
83
|
+
name?: unknown;
|
|
84
|
+
} | null;
|
|
85
|
+
status?: unknown;
|
|
86
|
+
} | null | undefined): void;
|
|
87
|
+
/**
|
|
88
|
+
* TBP-644 — replace `bridge.tenant.entitlements` from an `entitlements.changed`
|
|
89
|
+
* push that carries the map (the slice is documented as "replaced wholesale on
|
|
90
|
+
* every entitlements.changed push", but only `session.snapshot` ever wrote it).
|
|
91
|
+
* The signal-only lifecycle variant of the same kind carries no map and leaves
|
|
92
|
+
* the store untouched. Never throws.
|
|
93
|
+
*/
|
|
94
|
+
export declare function applyEntitlementsChanged(msg: {
|
|
95
|
+
entitlements?: unknown;
|
|
96
|
+
} | null | undefined): void;
|
|
68
97
|
/** Test-only: reset every snapshot store to `null`. Vitest hook. */
|
|
69
98
|
export declare function __resetSnapshotStores(): void;
|
|
@@ -53,6 +53,43 @@ export function applySessionSnapshot(data) {
|
|
|
53
53
|
if (data?.user)
|
|
54
54
|
_user.set(data.user);
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* TBP-644 — move `bridge.tenant.subscription` on a `subscription.plan_changed`
|
|
58
|
+
* push. Until now this store was written only by `session.snapshot`, and a plan
|
|
59
|
+
* change never re-sends one, so an upgraded workspace kept rendering its old
|
|
60
|
+
* plan until a reload — while auth-core's `useBridge().subscription`, hydrated
|
|
61
|
+
* from the very same push, already had the new one.
|
|
62
|
+
*
|
|
63
|
+
* The push is authoritative for plan and status (auth-core's contract for it:
|
|
64
|
+
* "consumers hydrate their cached state on receipt; no refetch required").
|
|
65
|
+
* Fields it does not carry (`endsAt`, `gateEngaged`) keep their current value.
|
|
66
|
+
* A payload without a plan slug is ignored. Never throws.
|
|
67
|
+
*/
|
|
68
|
+
export function applySubscriptionPlanChanged(msg) {
|
|
69
|
+
const slug = msg?.to?.slug;
|
|
70
|
+
if (typeof slug !== 'string' || slug === '')
|
|
71
|
+
return;
|
|
72
|
+
const name = typeof msg?.to?.name === 'string' ? msg.to.name : slug;
|
|
73
|
+
const status = typeof msg?.status === 'string' ? msg.status : undefined;
|
|
74
|
+
_tenantSubscription.update((current) => ({
|
|
75
|
+
...(current ?? {}),
|
|
76
|
+
plan: { slug, name },
|
|
77
|
+
status: status ?? current?.status ?? '',
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* TBP-644 — replace `bridge.tenant.entitlements` from an `entitlements.changed`
|
|
82
|
+
* push that carries the map (the slice is documented as "replaced wholesale on
|
|
83
|
+
* every entitlements.changed push", but only `session.snapshot` ever wrote it).
|
|
84
|
+
* The signal-only lifecycle variant of the same kind carries no map and leaves
|
|
85
|
+
* the store untouched. Never throws.
|
|
86
|
+
*/
|
|
87
|
+
export function applyEntitlementsChanged(msg) {
|
|
88
|
+
const map = msg?.entitlements;
|
|
89
|
+
if (!map || typeof map !== 'object' || Array.isArray(map))
|
|
90
|
+
return;
|
|
91
|
+
_tenantEntitlements.set({ ...map });
|
|
92
|
+
}
|
|
56
93
|
/** Test-only: reset every snapshot store to `null`. Vitest hook. */
|
|
57
94
|
export function __resetSnapshotStores() {
|
|
58
95
|
_appBranding.set(null);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.8.0-beta.
|
|
3
|
+
"version": "0.8.0-beta.1",
|
|
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",
|