@nebulr-group/bridge-cli 0.1.4 → 0.4.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.
- package/README.md +26 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/app.command.d.ts.map +1 -1
- package/dist/commands/app.command.js +2 -0
- package/dist/commands/app.command.js.map +1 -1
- package/dist/commands/flag-init.command.d.ts +13 -0
- package/dist/commands/flag-init.command.d.ts.map +1 -0
- package/dist/commands/flag-init.command.js +353 -0
- package/dist/commands/flag-init.command.js.map +1 -0
- package/dist/commands/flag.command.d.ts +92 -0
- package/dist/commands/flag.command.d.ts.map +1 -1
- package/dist/commands/flag.command.js +784 -25
- package/dist/commands/flag.command.js.map +1 -1
- package/dist/commands/guide.command.d.ts +21 -0
- package/dist/commands/guide.command.d.ts.map +1 -1
- package/dist/commands/guide.command.js +290 -23
- package/dist/commands/guide.command.js.map +1 -1
- package/dist/commands/integrate.command.js +3 -3
- package/dist/commands/integrate.command.js.map +1 -1
- package/dist/commands/ops.command.d.ts +17 -0
- package/dist/commands/ops.command.d.ts.map +1 -0
- package/dist/commands/ops.command.js +129 -0
- package/dist/commands/ops.command.js.map +1 -0
- package/dist/commands/plan.command.d.ts +31 -0
- package/dist/commands/plan.command.d.ts.map +1 -1
- package/dist/commands/plan.command.js +182 -1
- package/dist/commands/plan.command.js.map +1 -1
- package/dist/commands/runtime-dir.d.ts +16 -0
- package/dist/commands/runtime-dir.d.ts.map +1 -0
- package/dist/commands/runtime-dir.js +18 -0
- package/dist/commands/runtime-dir.js.map +1 -0
- package/dist/commands/stripe.command.d.ts +3 -0
- package/dist/commands/stripe.command.d.ts.map +1 -0
- package/dist/commands/stripe.command.js +43 -0
- package/dist/commands/stripe.command.js.map +1 -0
- package/dist/output.d.ts +8 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +10 -0
- package/dist/output.js.map +1 -1
- package/dist/prompts/architecture.md +560 -0
- package/dist/prompts/auth-master-integration-prompt.md +251 -0
- package/dist/prompts/billing/master.md +269 -0
- package/dist/prompts/flags/master.md +215 -0
- package/dist/prompts/integration-success.md +107 -0
- package/package.json +4 -3
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
# Bridge Live Channel — Architecture sketch
|
|
2
|
+
|
|
3
|
+
*Design doc. Not implementation. Goal: align on the dev-facing model before we open code.*
|
|
4
|
+
|
|
5
|
+
## The mental model — one sentence
|
|
6
|
+
|
|
7
|
+
A dev integrates Bridge once, gets a **single live channel** to Bridge for everything that changes (user, billing, flags, anything Bridge owns), and a **single read surface** with the current state of all of it. They can also **push their own state through the same surface** so flag rules and other Bridge primitives can target it. No polling. No "where do I fetch this". No three-different-paths-to-the-same-thing.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Bridge (server)
|
|
11
|
+
│
|
|
12
|
+
│ one channel — pushes scoped events
|
|
13
|
+
▼
|
|
14
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
15
|
+
│ bridge (single SDK surface) │
|
|
16
|
+
│ │
|
|
17
|
+
│ bridge.app ← branding · flags · plans (catalog) │
|
|
18
|
+
│ bridge.tenant ← subscription · quotas · entitlements │
|
|
19
|
+
│ bridge.user ← identity · role · preferences │
|
|
20
|
+
│ │
|
|
21
|
+
│ bridge.attributes ← read + write store (push your own) │
|
|
22
|
+
│ bridge.events ← lifecycle stream (handle reactions) │
|
|
23
|
+
│ │
|
|
24
|
+
└──────────────────────────────────────────────────────────────┘
|
|
25
|
+
▲
|
|
26
|
+
│ push my own keys / domain state
|
|
27
|
+
│
|
|
28
|
+
my app code
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Three **scopes** (`app`, `tenant`, `user`) split the read surface by *what changes* and *who's affected*. Two **cross-cutting** slices (`attributes`, `events`) sit alongside.
|
|
32
|
+
|
|
33
|
+
| Scope | Carries | Changes when | Affects |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| **`bridge.app`** | Branding, flag definitions, plans catalog | Admin changes app config | All users of the app |
|
|
36
|
+
| **`bridge.tenant`** | Subscription, quotas, entitlements, members, settings | Billing events, team changes | All users in that tenant |
|
|
37
|
+
| **`bridge.user`** | Identity (JWT-derived), role, preferences | User-specific events | Only this user |
|
|
38
|
+
|
|
39
|
+
## Server end — the channel
|
|
40
|
+
|
|
41
|
+
### Transport
|
|
42
|
+
|
|
43
|
+
One WebSocket per session, subscribed to two underlying channels (a third may be added later):
|
|
44
|
+
|
|
45
|
+
- **App channel** — `workspace:<appId>` today; cleanup target is `app:<appId>` — broadcast to anyone connected to the app.
|
|
46
|
+
- **Per-user channel** — `user:<userId>` — events scoped to one user; tenant-level events also fan out here per member for v1.
|
|
47
|
+
- **Tenant channel** — `tenant:<tenantId>` — *deferred*. When tenants grow large, this cuts the per-user fan-out for tenant events. Adding it later does not change the SDK surface.
|
|
48
|
+
|
|
49
|
+
Already in place (Centrifugo) for the first two; tenant channel is a future optimization.
|
|
50
|
+
|
|
51
|
+
### Event taxonomy — grouped by scope
|
|
52
|
+
|
|
53
|
+
Every message is a discriminated union with a `kind` string prefixed by its scope. The SDK demultiplexes by prefix and updates the matching slice.
|
|
54
|
+
|
|
55
|
+
| Scope | `kind` examples | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| **`app.*`** | `app.config_changed`, `app.branding_changed`, `flag.upsert`, `flag.remove`, `flag.bulk_invalidate`, `plan.catalog_changed` | App definitions changed; affects everyone connected to the app |
|
|
58
|
+
| **`tenant.*`** | `subscription.created`, `subscription.changed`, `subscription.cancelled`, `payment.failed`, `payment.succeeded`, `cancel.scheduled`, `cancel.effective`, `dunning.advanced`, `dunning.exhausted`, `quota.updated`, `entitlements.changed`, `tenant.members_changed`, `tenant.settings_changed` | Tenant state changed; affects all members of that tenant |
|
|
59
|
+
| **`user.*`** | `user.state_changed`, `user.role_changed`, `user.preferences_changed` | This specific user's state changed |
|
|
60
|
+
| **`system.*`** | `connection.open`, `connection.closed`, `connection.reauthorize`, `session.snapshot` | Channel lifecycle and initial state delivery |
|
|
61
|
+
|
|
62
|
+
Naming convention: `<scope>.<verb>`. The `flag.*` and `subscription.*` / `payment.*` etc. kinds keep their natural domain-verb names but are categorized as `app.*` and `tenant.*` respectively by the SDK demux — i.e. `kind` strings stay readable; the scope grouping is a routing concern.
|
|
63
|
+
|
|
64
|
+
### What the server guarantees
|
|
65
|
+
|
|
66
|
+
- **At-least-once delivery** while connected. Recovery on reconnect is best-effort today (Centrifugo recovery client-side is deferred; SDK re-hydrates on reconnect).
|
|
67
|
+
- **Causal ordering within a session** (Centrifugo per-channel).
|
|
68
|
+
- **No cross-tenant leakage** — every event is scoped to a workspace or a userId; server enforces.
|
|
69
|
+
|
|
70
|
+
## SDK end — the read surface
|
|
71
|
+
|
|
72
|
+
One root object, **`bridge`**, returned by a single bootstrap call. Every reactive slice hangs off it. Devs never instantiate sub-clients, never juggle multiple singletons.
|
|
73
|
+
|
|
74
|
+
### Top-level shape
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const bridge = await initBridge({
|
|
78
|
+
apiBaseUrl: 'https://api.thebridge.dev',
|
|
79
|
+
appId: import.meta.env.VITE_BRIDGE_APP_ID,
|
|
80
|
+
// optional: route guards, hosted-vs-SDK auth, etc.
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`initBridge()` returns a `Bridge` object structured by scope. Slices marked **snapshot** are pre-populated on connect and immediately reactive. Slices marked **lazy** start `null` and are populated by `.load()` (or `await bridge.X`), then stay reactive from that point on.
|
|
85
|
+
|
|
86
|
+
| Slice | Shape | Snapshot? | Source |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| **`bridge.app`** | | | |
|
|
89
|
+
| `bridge.app.branding` | Subscribable `{ logo, colors, name, … }` | **snapshot** | `app.branding_changed` |
|
|
90
|
+
| `bridge.app.flags` | `bridge.app.flags.flag<T>(key, default)` + reactive accessors | n/a — evaluated, not state | `flag.*` events |
|
|
91
|
+
| `bridge.app.plans` | Subscribable plan catalog | **lazy** | REST + `plan.catalog_changed` |
|
|
92
|
+
| `bridge.app.flags.definitions` | Flag definition catalog (debug tooling) | **lazy** | REST + `flag.*` |
|
|
93
|
+
| **`bridge.tenant`** | | | |
|
|
94
|
+
| `bridge.tenant.subscription` | Subscribable `{ plan, status, trial, … }` | **snapshot** | `subscription.*` |
|
|
95
|
+
| `bridge.tenant.entitlements` | Subscribable `{ can(key) → boolean, snapshot }` | **snapshot** | `entitlements.changed` |
|
|
96
|
+
| `bridge.tenant.quotas` | Subscribable `Map<metric, QuotaSnapshot>` | **lazy** | `quota.updated` |
|
|
97
|
+
| `bridge.tenant.members` | Subscribable member list | **lazy** | REST + `tenant.members_changed` |
|
|
98
|
+
| `bridge.tenant.settings` | Subscribable tenant settings | **lazy** | REST + `tenant.settings_changed` |
|
|
99
|
+
| **`bridge.user`** | | | |
|
|
100
|
+
| `bridge.user` (root) | Subscribable `{ id, email, role, tenantId, isAuthenticated }` | **snapshot** | JWT + `user.state_changed` |
|
|
101
|
+
| `bridge.user.preferences` | Subscribable preferences map | **lazy** | REST + `user.preferences_changed` |
|
|
102
|
+
| **Cross-cutting** | | | |
|
|
103
|
+
| `bridge.attributes` | **Read + Write** map of all attributes in eval context | n/a | Internal providers + dev pushes |
|
|
104
|
+
| `bridge.events` | Multiplexed stream of every `kind` for lifecycle handlers | n/a | Raw channel |
|
|
105
|
+
|
|
106
|
+
Every slice exposes the **same** subscribe shape (Svelte store contract, framework's idiomatic reactive primitive — picked per-plugin), so the dev never has to learn slice-specific conventions.
|
|
107
|
+
|
|
108
|
+
### Snapshot vs lazy — the dev pattern
|
|
109
|
+
|
|
110
|
+
Snapshot slices: read them. They're never null.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const sub = $bridge.tenant.subscription; // { plan, status, … }
|
|
114
|
+
const can = $bridge.tenant.entitlements.can('ai_completions');
|
|
115
|
+
const role = $bridge.user.role;
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Lazy slices: load once, react forever.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// First touch — fetch via REST + start tracking channel deltas
|
|
122
|
+
await bridge.tenant.quotas.load();
|
|
123
|
+
|
|
124
|
+
// Or: awaiting the slice itself triggers load-if-needed
|
|
125
|
+
const quotas = await bridge.tenant.quotas;
|
|
126
|
+
|
|
127
|
+
// Once loaded, no more awaits needed — it's a regular reactive slice
|
|
128
|
+
$bridge.tenant.quotas.get('ai_completions')?.percent_used;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Important rule:** once loaded, channel updates keep a lazy slice fresh. No re-fetching, no polling. The `.load()` call is one-shot priming.
|
|
132
|
+
|
|
133
|
+
**Why this composition:** cold connect carries only what's needed for a clean first paint (a few KB). Pages that need more — billing dashboard, team page — pay the load cost when navigated to, not at session start. Snapshot composition is **fixed** (no per-app configuration knob) — keeps the contract simple and lets Bridge tune the defaults if usage data demands it.
|
|
134
|
+
|
|
135
|
+
### Single write surface — pushing your own state
|
|
136
|
+
|
|
137
|
+
`bridge.attributes` is the *only* place dev code pushes keys into the eval context. Internally Bridge's own providers (auth, billing) also write here — so dev keys and Bridge-managed keys live in one place, with explicit namespace separation:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
bridge.attributes.get()
|
|
141
|
+
// {
|
|
142
|
+
// "user.role": "owner", ← Bridge-managed
|
|
143
|
+
// "tenant.plan": "pro", ← Bridge-managed
|
|
144
|
+
// "bridge:billing.entitlement.ai": true, ← Bridge-managed
|
|
145
|
+
// "app.cohort": "beta", ← dev-pushed
|
|
146
|
+
// "app.theme": "dark", ← dev-pushed
|
|
147
|
+
// }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Devs use one of three write styles depending on the shape of their source:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// 1. Static one-shot value
|
|
154
|
+
bridge.attributes.set('app.cohort', 'beta');
|
|
155
|
+
|
|
156
|
+
// 2. Bound to a reactive source — Bridge re-reads on every flag eval
|
|
157
|
+
bridge.attributes.bind('app.theme', () => themeStore.value);
|
|
158
|
+
|
|
159
|
+
// 3. Bulk source — register a function that returns multiple keys
|
|
160
|
+
bridge.attributes.bindMany(() => ({
|
|
161
|
+
'app.cohort': currentCohort(),
|
|
162
|
+
'app.workspace_id': activeWorkspace.id,
|
|
163
|
+
'app.onboarding_step': onboarding.step,
|
|
164
|
+
}));
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
There is no `AttributeProvider` class to implement, no registry to know about. The SDK exposes the simplest possible verbs (`set` / `bind` / `bindMany`) and handles the provider plumbing internally.
|
|
168
|
+
|
|
169
|
+
Per-call attribute overrides for transient values stay on the component (`<FeatureFlag context={...}>` or `bridge.flags.flag('key', default, { attributes: {…} })`) — they're the "just this once" path and don't conflict with the global store.
|
|
170
|
+
|
|
171
|
+
### Single lifecycle handler — reacting to events
|
|
172
|
+
|
|
173
|
+
If a dev wants to react to specific server-pushed events (show a toast on `payment.failed`, force-refresh some local state on `subscription.changed`), they subscribe through `bridge.events`:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const unsubscribe = bridge.events.handle({
|
|
177
|
+
'payment.failed': (e) => showToast('Card declined — update billing'),
|
|
178
|
+
'subscription.changed': (e) => analytics.track('plan_changed', e),
|
|
179
|
+
'flag.upsert': (e) => console.debug('flag updated', e.key),
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Returns a single unsubscribe. One stream, one subscribe API, no per-domain emitter.
|
|
184
|
+
|
|
185
|
+
## What the Svelte dev sees
|
|
186
|
+
|
|
187
|
+
Everything below uses Svelte 5 runes + stores. Other plugins (`bridge-react`, `bridge-nextjs`, etc.) mirror the same surface using their idiomatic primitives.
|
|
188
|
+
|
|
189
|
+
### Wire-up — once in `+layout.svelte`
|
|
190
|
+
|
|
191
|
+
```svelte
|
|
192
|
+
<script lang="ts">
|
|
193
|
+
import { initBridge, BridgeProvider } from '@nebulr-group/bridge-svelte';
|
|
194
|
+
|
|
195
|
+
const bridge = initBridge({
|
|
196
|
+
apiBaseUrl: import.meta.env.VITE_BRIDGE_API_BASE_URL,
|
|
197
|
+
appId: import.meta.env.VITE_BRIDGE_APP_ID,
|
|
198
|
+
routeGuard: { /* … */ },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let { children } = $props();
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
<BridgeProvider {bridge}>
|
|
205
|
+
{@render children()}
|
|
206
|
+
</BridgeProvider>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
That's the whole bootstrap. No separate `bridgeBootstrap` + `createBridgeFlags` + `useBridge` + `getBridgeAuth` calls. One.
|
|
210
|
+
|
|
211
|
+
### Reading user state (snapshot — always available)
|
|
212
|
+
|
|
213
|
+
```svelte
|
|
214
|
+
<script lang="ts">
|
|
215
|
+
import { useBridge } from '@nebulr-group/bridge-svelte';
|
|
216
|
+
const { user } = useBridge();
|
|
217
|
+
</script>
|
|
218
|
+
|
|
219
|
+
{#if $user.isAuthenticated}
|
|
220
|
+
<p>Hi {$user.email} — you're a {$user.role}.</p>
|
|
221
|
+
{:else}
|
|
222
|
+
<a href="/auth/login">Sign in</a>
|
|
223
|
+
{/if}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`$user` re-renders the moment `user.state_changed` fires; the JWT refreshes underneath, the slice updates, no manual work.
|
|
227
|
+
|
|
228
|
+
### Reading tenant subscription + entitlements (snapshot)
|
|
229
|
+
|
|
230
|
+
```svelte
|
|
231
|
+
<script lang="ts">
|
|
232
|
+
import { useBridge } from '@nebulr-group/bridge-svelte';
|
|
233
|
+
const { tenant } = useBridge();
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<p>Plan: {$tenant.subscription.plan}</p>
|
|
237
|
+
|
|
238
|
+
{#if $tenant.entitlements.can('ai_completions')}
|
|
239
|
+
<AICompose />
|
|
240
|
+
{:else}
|
|
241
|
+
<UpgradePrompt />
|
|
242
|
+
{/if}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Both are in the snapshot — available on first paint. Updates apply live as `subscription.*` and `entitlements.changed` arrive.
|
|
246
|
+
|
|
247
|
+
### Reading quotas (lazy — explicit load on the page that needs it)
|
|
248
|
+
|
|
249
|
+
```svelte
|
|
250
|
+
<script lang="ts">
|
|
251
|
+
import { useBridge } from '@nebulr-group/bridge-svelte';
|
|
252
|
+
import { onMount } from 'svelte';
|
|
253
|
+
const { tenant } = useBridge();
|
|
254
|
+
|
|
255
|
+
onMount(() => tenant.quotas.load()); // priming fetch
|
|
256
|
+
</script>
|
|
257
|
+
|
|
258
|
+
{#if $tenant.quotas}
|
|
259
|
+
<p>AI quota: {$tenant.quotas.get('ai_completions')?.percent_used}% used</p>
|
|
260
|
+
{:else}
|
|
261
|
+
<SkeletonRow />
|
|
262
|
+
{/if}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Quota counter ticks live as `quota.updated` events arrive — once loaded.
|
|
266
|
+
|
|
267
|
+
### Using a feature flag
|
|
268
|
+
|
|
269
|
+
```svelte
|
|
270
|
+
<script lang="ts">
|
|
271
|
+
import { FeatureFlag } from '@nebulr-group/bridge-svelte';
|
|
272
|
+
</script>
|
|
273
|
+
|
|
274
|
+
<FeatureFlag key="new-dashboard" defaultValue={false}>
|
|
275
|
+
{#snippet children()}<NewDashboard />{/snippet}
|
|
276
|
+
{#snippet fallback()}<OldDashboard />{/snippet}
|
|
277
|
+
</FeatureFlag>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
If the flag's rule references **any** attribute — `user.role`, `tenant.plan`, `bridge:billing.entitlement.ai`, `app.cohort`, anything — the SDK pulls the live value from `bridge.attributes` and re-evaluates on every relevant push. No dev wiring per rule, ever. The component is sugar over `bridge.app.flags.flag(key, default)`.
|
|
281
|
+
|
|
282
|
+
### Pushing your own attributes
|
|
283
|
+
|
|
284
|
+
```svelte
|
|
285
|
+
<script lang="ts">
|
|
286
|
+
import { useBridge } from '@nebulr-group/bridge-svelte';
|
|
287
|
+
import { themeStore, onboardingStep } from '$lib/state';
|
|
288
|
+
|
|
289
|
+
const bridge = useBridge();
|
|
290
|
+
|
|
291
|
+
// Static — set once when you know it
|
|
292
|
+
bridge.attributes.set('app.cohort', getCohort());
|
|
293
|
+
|
|
294
|
+
// Live-bound — Bridge re-reads on every flag eval
|
|
295
|
+
bridge.attributes.bind('app.theme', () => $themeStore);
|
|
296
|
+
bridge.attributes.bind('app.onboarding_step', () => $onboardingStep);
|
|
297
|
+
</script>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The admin's rule builder auto-discovers these keys the first time a flag is evaluated with them in context — no manual schema declaration. Now an admin can write a flag rule like `app.cohort == "beta" AND bridge:billing.plan == "pro"` and it works.
|
|
301
|
+
|
|
302
|
+
### Reacting to lifecycle events
|
|
303
|
+
|
|
304
|
+
```svelte
|
|
305
|
+
<script lang="ts">
|
|
306
|
+
import { useBridge } from '@nebulr-group/bridge-svelte';
|
|
307
|
+
import { toast } from '$lib/ui/toast';
|
|
308
|
+
import { onDestroy } from 'svelte';
|
|
309
|
+
|
|
310
|
+
const { events } = useBridge();
|
|
311
|
+
|
|
312
|
+
const stop = events.handle({
|
|
313
|
+
'payment.failed': (e) => toast.error(`Card declined — update billing`),
|
|
314
|
+
'subscription.changed': (e) => toast.info(`Plan changed to ${e.plan}`),
|
|
315
|
+
'flag.upsert': (e) => console.debug('[bridge] flag updated', e.key),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
onDestroy(stop);
|
|
319
|
+
</script>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### What the dev never has to think about
|
|
323
|
+
|
|
324
|
+
- Token refresh — happens automatically when `user.state_changed` arrives.
|
|
325
|
+
- Cache hydration / invalidation — handled internally per slice.
|
|
326
|
+
- Channel reconnect — automatic; `bridge.events` exposes `connection.open` / `connection.closed` for UI status pills if wanted.
|
|
327
|
+
- Which slice owns which event — slices update themselves.
|
|
328
|
+
- Provider classes, registries, attribute-provider registration — none of those concepts exist at the dev surface.
|
|
329
|
+
|
|
330
|
+
## What this replaces (and why)
|
|
331
|
+
|
|
332
|
+
| Today | Target |
|
|
333
|
+
|---|---|
|
|
334
|
+
| `bridgeBootstrap()` + `<BridgeBootstrap>` + `getBridgeAuth()` + `useBridge()` + `createBridgeFlags()` + `loadSubscription()` + tokenStore subscribe | One `initBridge()` + `<BridgeProvider>` + `useBridge()` |
|
|
335
|
+
| Per-domain stores reached through different APIs (`subscriptionStore`, `featureFlags.flags`, `useBridge().quotas`, etc.) | Scoped slices off the same root (`bridge.app.*` / `bridge.tenant.*` / `bridge.user.*`), same subscribe shape |
|
|
336
|
+
| Multiple REST hydrate calls on bootstrap (`/subscription`, `/quotas`, `/entitlements`, …) | One `session.snapshot` over the channel + `.load()` per lazy slice on demand |
|
|
337
|
+
| Three ways to push attributes (`tokenStore→setContext`, `AttributeProvider`, per-call `context`) | One write surface: `bridge.attributes.set / bind / bindMany`. Per-call `context` survives for transient overrides only. |
|
|
338
|
+
| Per-domain event subscriptions (handle billing on `useBridge().handle`, react to flag changes via the flag store, etc.) | One `bridge.events.handle({...})` for everything, dispatched by `kind` |
|
|
339
|
+
| Backend SDKs are a separate world from frontend SDKs | Same `bridge.*` surface; `mode: 'channel' \| 'pull'` knob picks how state stays fresh |
|
|
340
|
+
|
|
341
|
+
## Settled design decisions
|
|
342
|
+
|
|
343
|
+
All open questions from the design pass have been resolved:
|
|
344
|
+
|
|
345
|
+
1. ✅ **Bridge instance access pattern** — Context provider + `useBridge()` hook is canonical across every plugin (Svelte / React / NextJS / Angular / NestJS / Express). One name, one provider component (`<BridgeProvider>`).
|
|
346
|
+
2. ✅ **Attribute collision semantics** — Dev-set keys override Bridge-managed keys on the same key (preserves locked decision #20). Namespace prefixes (`bridge:*`, `app.*`, `user.*`, `tenant.*`) discourage accidental clobber. `bridge:*` is **reserved** — dev writes into it are rejected.
|
|
347
|
+
3. ✅ **Per-call `context` on `<FeatureFlag>` / `useFlag()`** — kept as a power-user / transient override path. Not the standard. The standard is `bridge.attributes.bind()`. The per-call path exists for "value of this `<select>` right now" without polluting the global store.
|
|
348
|
+
4. ✅ **Event handler shape** — `bridge.events.handle({...})` — discriminated-union dispatcher, one call, one unsubscribe, matches the existing billing handle ergonomics.
|
|
349
|
+
5. ✅ **Future domains** — Channel is universal; new domains are new slices off `bridge` with new `kind` prefixes. No new transport, no new mental model.
|
|
350
|
+
6. ✅ **Backend SDKs** — Same `bridge.*` surface as frontend. Backend devs pick a `mode` at init time: `channel` (long-running, keeps WebSocket, gets live updates) or `pull` (ephemeral / serverless, REST per read, no WebSocket). See "Backend SDK modes" below.
|
|
351
|
+
7. ✅ **Channel-on-connect snapshot** — Minimal snapshot of first-paint essentials (`app.branding`, `tenant.subscription`, `tenant.entitlements`, `user.*`). Everything else lazy-loaded via `.load()`. Snapshot composition is fixed; no per-app or per-init configurability.
|
|
352
|
+
8. ✅ **Three scopes** — `bridge.app` / `bridge.tenant` / `bridge.user` mirrors both the snapshot structure and the event-kind prefixes. Naming is consistent across server-side topology, SDK surface, and admin tooling.
|
|
353
|
+
|
|
354
|
+
## Backend SDK modes
|
|
355
|
+
|
|
356
|
+
A single `mode` knob picks how state stays fresh; everything else (the `bridge.app/tenant/user` slices, `events.handle`, `attributes`) is identical across frontend and backend.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
// Long-running backend (default) — opens a channel, gets live updates.
|
|
360
|
+
// Use for: NestJS / Express servers, anything running 24/7.
|
|
361
|
+
const bridge = await initBridge({
|
|
362
|
+
appId: process.env.BRIDGE_APP_ID,
|
|
363
|
+
apiKey: process.env.BRIDGE_API_KEY,
|
|
364
|
+
mode: 'channel',
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Ephemeral backend — no channel, every read = REST.
|
|
368
|
+
// Use for: cron jobs, serverless / edge functions, webhook handlers, CLI scripts.
|
|
369
|
+
const bridge = await initBridge({
|
|
370
|
+
appId: process.env.BRIDGE_APP_ID,
|
|
371
|
+
apiKey: process.env.BRIDGE_API_KEY,
|
|
372
|
+
mode: 'pull',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Pull-mode: read fresh on demand
|
|
376
|
+
await bridge.refresh(); // re-pull session snapshot
|
|
377
|
+
await bridge.tenant(tenantId).subscription; // REST, fresh every call
|
|
378
|
+
await bridge.users(userId).entitlements; // REST, fresh every call
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
What pull-mode gives up:
|
|
382
|
+
- No live updates — state is only known at fetch time.
|
|
383
|
+
- No `bridge.events.handle()` — nothing pushes to you. Use **Bridge webhooks** (separate, persistent) for server-side event-driven reactions in pull mode.
|
|
384
|
+
- No automatic cache invalidation — the SDK caches briefly (configurable, ~30 s default) to spare the hot path; `bridge.refresh()` forces a fresh read.
|
|
385
|
+
|
|
386
|
+
What pull-mode gains:
|
|
387
|
+
- Cold start cost = zero (no WebSocket handshake).
|
|
388
|
+
- Works in any runtime, including serverless cold paths and edge functions where long-lived sockets are awkward.
|
|
389
|
+
- Predictable RPC-shaped traffic, easy to reason about and bill.
|
|
390
|
+
|
|
391
|
+
Backends also get **multi-tenant / multi-user accessors** that frontends don't need: `bridge.tenant(id)`, `bridge.users(id)`. A backend serves many tenants and many users; it doesn't subscribe to one user's channel like a frontend does.
|
|
392
|
+
|
|
393
|
+
## REST endpoints — separate, supported surface
|
|
394
|
+
|
|
395
|
+
The channel-snapshot model handles **live SDK consumers** — apps that want to stay in sync with Bridge. REST endpoints handle a different class of need and must remain (and grow as needed):
|
|
396
|
+
|
|
397
|
+
- **Backend / server-to-server reads** — a developer's API server fetching subscription state for an authenticated user, sending Bridge data into their analytics warehouse, syncing user records into a CRM, etc.
|
|
398
|
+
- **One-shot reads** — anywhere a long-lived channel doesn't make sense (cron jobs, webhook handlers, mobile background tasks, scripts).
|
|
399
|
+
- **Devs building their own integrations** — anyone exporting Bridge data into other systems for any reason. Bridge has to be a good citizen here; REST is the universal interface.
|
|
400
|
+
- **The unified SDK uses REST internally** for paths that aren't channel-correlated (browsing plans, fetching tenant member lists on demand, etc.).
|
|
401
|
+
|
|
402
|
+
The rule: **every domain that has channel events also has REST equivalents.** Adding a new slice to the channel implies the corresponding REST endpoint exists (or gets added in the same change). The SDK chooses channel-first; the dev chooses whichever fits their use case.
|
|
403
|
+
|
|
404
|
+
## Initial state delivery — channel-on-connect snapshot
|
|
405
|
+
|
|
406
|
+
On connect to the per-user channel, the server emits a single `session.snapshot` message. It carries **only the first-paint essentials** — small, predictable, scoped:
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"kind": "session.snapshot",
|
|
411
|
+
"data": {
|
|
412
|
+
"app": {
|
|
413
|
+
"branding": { "logo": "...", "colors": {...}, "name": "..." }
|
|
414
|
+
},
|
|
415
|
+
"tenant": {
|
|
416
|
+
"id": "...",
|
|
417
|
+
"name": "...",
|
|
418
|
+
"subscription": { "plan": "pro", "status": "active", "trial": false },
|
|
419
|
+
"entitlements": { "ai_completions": true, "app_active": true, … }
|
|
420
|
+
},
|
|
421
|
+
"user": {
|
|
422
|
+
"id": "...",
|
|
423
|
+
"email": "...",
|
|
424
|
+
"role": "owner",
|
|
425
|
+
"tenantId": "..."
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
What's deliberately **not** in the snapshot — populated via `.load()` when the dev's UI asks for it:
|
|
432
|
+
|
|
433
|
+
- `tenant.quotas` (full per-metric counters)
|
|
434
|
+
- `tenant.members` (team list)
|
|
435
|
+
- `tenant.settings` (admin settings)
|
|
436
|
+
- `app.plans` (full plan catalog)
|
|
437
|
+
- `app.flags.definitions` (flag-definitions catalog — debug tooling)
|
|
438
|
+
- `user.preferences` (full preferences set)
|
|
439
|
+
|
|
440
|
+
SDK behavior:
|
|
441
|
+
- Snapshot slices are immediately reactive on connect. First paint reflects real state, no flicker.
|
|
442
|
+
- Lazy slices remain `null` until `bridge.X.load()` (or `await bridge.X`) is called. After load, channel updates keep them fresh.
|
|
443
|
+
- Subsequent channel events (`subscription.changed`, `quota.updated`, `entitlements.changed`, etc.) apply as deltas to whichever slices are populated.
|
|
444
|
+
- Reconnect after offline re-emits the snapshot. Any lazy slices the dev had loaded get re-hydrated by their own REST fetches automatically.
|
|
445
|
+
|
|
446
|
+
Snapshot composition is **fixed** — no per-app, no per-init configuration knob. Bridge picks the defaults; if real usage shows another slice belongs in the snapshot, we promote it without anyone changing code (their explicit `.load()` calls keep working as no-ops once the slice is pre-populated).
|
|
447
|
+
|
|
448
|
+
The snapshot is **also fetchable via REST** at `GET /session/init` so non-channel consumers (server-side, cron, serverless) can get the same shape in one call. Same endpoint, different transport.
|
|
449
|
+
|
|
450
|
+
## What changes for us (cleanup scope, no code yet)
|
|
451
|
+
|
|
452
|
+
If we settle on this, the engineering work that follows is roughly:
|
|
453
|
+
|
|
454
|
+
1. **auth-core**
|
|
455
|
+
- One internal attribute store (replaces the three current paths).
|
|
456
|
+
- `BridgeFlags.flag()` reads attributes from that store every eval.
|
|
457
|
+
- Channel demultiplexer routes events to the right internal slice subscribers.
|
|
458
|
+
2. **bridge-svelte**
|
|
459
|
+
- One `initBridge()` + `BridgeProvider` + `useBridge()` (consolidates `bridgeBootstrap`, `createBridgeFlags`, `getBridgeAuth`, current `useBridge`).
|
|
460
|
+
- `bridge.attributes.set / bind / bindMany` as the only public write API.
|
|
461
|
+
- Deprecate (then remove) `getBridgeAuth()`, `loadFeatureFlags()`, `subscriptionStore` etc. as direct exports — kept internally; surfaced through slices.
|
|
462
|
+
3. **bridge-nextjs / bridge-react / others** — port the same surface; identical mental model, framework-idiomatic primitives.
|
|
463
|
+
4. **bridge-api**
|
|
464
|
+
- Confirm the channel event kinds match the taxonomy above (rename / consolidate where they drifted).
|
|
465
|
+
- Auto-discover dev-pushed attribute keys from observed flag evals (already done for the FF 2.0 path; verify it still works after consolidation).
|
|
466
|
+
5. **Docs + prompts**
|
|
467
|
+
- Rewrite `mcp/feature-flags-prompt.md`, `mcp/payments-prompt.md`, `mcp/billing-prompt.md`, `mcp/integration-prompt.md` against the unified surface.
|
|
468
|
+
- Single `mcp/integration-prompt.md` is enough for the basics — feature-flags / payments / billing become incremental additions, not separate worlds.
|
|
469
|
+
|
|
470
|
+
This is the order we'd ship in. Not now — only after we agree on the architecture above.
|
|
471
|
+
|
|
472
|
+
## Security model
|
|
473
|
+
|
|
474
|
+
The live-channel design inherits its trust model from the JWT. This section pins down the rules so we don't drift.
|
|
475
|
+
|
|
476
|
+
### What the JWT carries vs what the channel carries
|
|
477
|
+
|
|
478
|
+
The JWT stays **exactly as it is today** — identity + sensitive claims (sub, email, role, tenant.id, tenant.plan, privileges). We deliberately do **not** bloat it with billing state, quotas, entitlements, or app data — those are non-sensitive enough to ride the channel and high-frequency enough that a token refresh per change would be wasteful.
|
|
479
|
+
|
|
480
|
+
| Data | Source | Rationale |
|
|
481
|
+
|---|---|---|
|
|
482
|
+
| User identity (`sub`, email, role, tenant.id, tenant.plan, privileges) | JWT | Sensitive, signed, slow-changing |
|
|
483
|
+
| Subscription status, entitlement booleans, quota counters | Channel push | Not PII, high-frequency, no reason to gate behind a token refresh |
|
|
484
|
+
| Flag values and rules | Channel push | Not sensitive on their own |
|
|
485
|
+
| Custom `app.*` attributes the dev pushes | Local SDK store (never serialized back) | Dev-owned; namespaced; observed-value telemetry is opt-out per key |
|
|
486
|
+
|
|
487
|
+
The channel is authorized **by the JWT**. There is no second auth mechanism. Same trust boundary, single rotation.
|
|
488
|
+
|
|
489
|
+
### Blast-radius bound = JWT TTL
|
|
490
|
+
|
|
491
|
+
A leaked or stolen JWT enables hijack of the per-user channel **only until the JWT expires**. On expiry:
|
|
492
|
+
- The hijacker cannot open a new connection (authorize rejects the expired JWT).
|
|
493
|
+
- The legitimate client refreshes its JWT and the SDK re-authorizes its channel connection with the fresh token.
|
|
494
|
+
- The hijacker's connection (if still open on the old JWT) must be terminated — see "Renewal cleanup" below.
|
|
495
|
+
|
|
496
|
+
This means JWT TTL is also the live-channel exposure window. If we ever shorten the JWT TTL for API security reasons, the channel benefits automatically.
|
|
497
|
+
|
|
498
|
+
### Renewal cleanup — two follow-up items to address before the unified surface ships
|
|
499
|
+
|
|
500
|
+
1. **Force channel re-authorize on every JWT refresh.** Today, the bridge-svelte bootstrap subscribes to `tokenStore`, but only triggers a channel reconnect when the userId changes. On a pure expiry-driven refresh (same user, new token), the existing connection rides on the *old* JWT until Centrifugo's own connection-token TTL terminates it. Tighten this so every `tokenStore` update forces a re-authorize — the moment the new JWT is in hand, the old one can no longer carry the channel.
|
|
501
|
+
2. **Server-side forced disconnect on revocation.** Logout, suspected-compromise flagging, or admin-initiated session kills should force-drop the user's active channel connection. Bridge today has a token-revocation concept but no path that publishes a disconnect signal to Centrifugo for that user. Add the publish path.
|
|
502
|
+
|
|
503
|
+
### Per-user channel auth is the load-bearing check
|
|
504
|
+
|
|
505
|
+
The unified surface's whole privacy model rests on one server-side assertion: a client trying to subscribe to `user:<userId>` must present a JWT whose `sub == userId`. Channel authorize **must not** accept the workspace API key alone (which every logged-in user of the app possesses).
|
|
506
|
+
|
|
507
|
+
This is the current behavior — the bootstrap deliberately passes the user JWT to `realtime.authorize` (`bridge-svelte/src/lib/flags/bootstrap.ts`). Locking it in:
|
|
508
|
+
|
|
509
|
+
- **e2e regression test** — subscribe to `user:<otherUserId>` with my own JWT; assert reject.
|
|
510
|
+
- **unit test** at the auth-core authorize layer asserting the same.
|
|
511
|
+
|
|
512
|
+
### App channel is for non-sensitive app-wide events only
|
|
513
|
+
|
|
514
|
+
The app channel (`workspace:<appId>` today, target rename `app:<appId>`) is broadcast to anyone connected to the app. It carries:
|
|
515
|
+
- Flag mutations (`flag.upsert`, `flag.remove`, `flag.bulk_invalidate`) — visible-to-all is acceptable; flag existence is already probe-able.
|
|
516
|
+
- App config / branding changes.
|
|
517
|
+
- Connection lifecycle.
|
|
518
|
+
|
|
519
|
+
It must **not** carry anything user-correlated or tenant-correlated. Discipline: when adding a new event kind, default it to the per-user (or future tenant) channel; promote to the app channel only if it's genuinely app-wide and non-sensitive.
|
|
520
|
+
|
|
521
|
+
### Client-side eval is UI, not access
|
|
522
|
+
|
|
523
|
+
The SDK's flag and entitlement evaluations are **client-side** for UX. A dev who writes `if (bridge.flags.flag('x', false)) { allowDangerousAction(); }` has shipped a vulnerability — any user can flip their local SDK state to override the result. This is not new and not unique to Bridge; it applies to every client-side feature flag system.
|
|
524
|
+
|
|
525
|
+
The fix is **documentation discipline**, not architectural defense. The integration prompt and the feature-flags prompt both state:
|
|
526
|
+
- Client-side flag results gate **UI rendering**.
|
|
527
|
+
- Any server endpoint that does work must re-check the entitlement/flag independently.
|
|
528
|
+
|
|
529
|
+
We are not designing around devs sabotaging their own apps.
|
|
530
|
+
|
|
531
|
+
### Custom-attribute namespace + observation
|
|
532
|
+
|
|
533
|
+
`bridge:` is reserved for Bridge-managed attributes. Dev pushes must use a different prefix (recommended: `app.*`). The SDK should reject (or log-warn-and-ignore) writes into `bridge:`.
|
|
534
|
+
|
|
535
|
+
The attribute-observation telemetry (sample-value catalog for admin autocomplete, TBP-178) is **on by default but opt-out per key**:
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
bridge.attributes.set('app.email', user.email, { observed: false });
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Documentation must spell out: don't push PII into observed attributes. The prompt should call this out with an example.
|
|
542
|
+
|
|
543
|
+
### Token storage on the client
|
|
544
|
+
|
|
545
|
+
Out of scope for this doc, but worth mentioning: where the JWT lives determines the XSS blast radius. Frameworks that support HttpOnly cookies should default to them; localStorage fallback is acceptable with CSP. The channel inherits whatever trust model the token storage gives.
|
|
546
|
+
|
|
547
|
+
### Things we are NOT designing for
|
|
548
|
+
|
|
549
|
+
- **MITM during transit** — WSS (TLS) handles this. Not novel.
|
|
550
|
+
- **Compromised admin / Bridge insiders** — out of scope for the live-channel design.
|
|
551
|
+
- **Side-channel timing attacks via flag-eval latency** — not a meaningful attack surface for typical applications.
|
|
552
|
+
|
|
553
|
+
### Summary
|
|
554
|
+
|
|
555
|
+
- Sensitive data stays in the JWT.
|
|
556
|
+
- Channel carries everything else, authorized by the same JWT.
|
|
557
|
+
- JWT TTL = blast-radius bound.
|
|
558
|
+
- Two cleanup follow-ups before the unified surface ships: force re-auth on every refresh, and server-side forced disconnect.
|
|
559
|
+
- Per-user channel auth is the one assertion that must never regress — locked in via test.
|
|
560
|
+
- Client-side eval discipline is a docs problem, not an architecture problem.
|