@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) => bridgeEvents._dispatch(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) => bridgeEvents._dispatch(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
+ });
@@ -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 active; otherwise transparently falls back to
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>;
@@ -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.0",
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",