@nebulr-group/bridge-auth-core 0.1.2 → 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.
Files changed (132) hide show
  1. package/dist/billing/bridge-subscription.d.ts +71 -0
  2. package/dist/billing/bridge-subscription.d.ts.map +1 -0
  3. package/dist/billing/bridge-subscription.js +209 -0
  4. package/dist/billing/bridge-subscription.js.map +1 -0
  5. package/dist/billing/entitlements-store.d.ts +61 -0
  6. package/dist/billing/entitlements-store.d.ts.map +1 -0
  7. package/dist/billing/entitlements-store.js +127 -0
  8. package/dist/billing/entitlements-store.js.map +1 -0
  9. package/dist/billing/fetch-billing-state.d.ts +9 -0
  10. package/dist/billing/fetch-billing-state.d.ts.map +1 -0
  11. package/dist/billing/fetch-billing-state.js +22 -0
  12. package/dist/billing/fetch-billing-state.js.map +1 -0
  13. package/dist/billing/lock-signal.d.ts +6 -0
  14. package/dist/billing/lock-signal.d.ts.map +1 -0
  15. package/dist/billing/lock-signal.js +8 -0
  16. package/dist/billing/lock-signal.js.map +1 -0
  17. package/dist/billing/quota-store.d.ts +86 -0
  18. package/dist/billing/quota-store.d.ts.map +1 -0
  19. package/dist/billing/quota-store.js +180 -0
  20. package/dist/billing/quota-store.js.map +1 -0
  21. package/dist/billing/types.d.ts +64 -0
  22. package/dist/billing/types.d.ts.map +1 -0
  23. package/dist/billing/types.js +49 -0
  24. package/dist/billing/types.js.map +1 -0
  25. package/dist/billing/use-bridge.d.ts +131 -0
  26. package/dist/billing/use-bridge.d.ts.map +1 -0
  27. package/dist/billing/use-bridge.js +181 -0
  28. package/dist/billing/use-bridge.js.map +1 -0
  29. package/dist/bridge-auth.d.ts +39 -1
  30. package/dist/bridge-auth.d.ts.map +1 -1
  31. package/dist/bridge-auth.js +89 -6
  32. package/dist/bridge-auth.js.map +1 -1
  33. package/dist/direct-auth.d.ts +1 -0
  34. package/dist/direct-auth.d.ts.map +1 -1
  35. package/dist/direct-auth.js +7 -0
  36. package/dist/direct-auth.js.map +1 -1
  37. package/dist/errors.d.ts +11 -0
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js +14 -0
  40. package/dist/errors.js.map +1 -1
  41. package/dist/flags/attribute-providers.d.ts +229 -0
  42. package/dist/flags/attribute-providers.d.ts.map +1 -0
  43. package/dist/flags/attribute-providers.js +375 -0
  44. package/dist/flags/attribute-providers.js.map +1 -0
  45. package/dist/flags/dev-attribute-provider.d.ts +67 -0
  46. package/dist/flags/dev-attribute-provider.d.ts.map +1 -0
  47. package/dist/flags/dev-attribute-provider.js +200 -0
  48. package/dist/flags/dev-attribute-provider.js.map +1 -0
  49. package/dist/flags/evaluator.d.ts +58 -0
  50. package/dist/flags/evaluator.d.ts.map +1 -0
  51. package/dist/flags/evaluator.js +160 -0
  52. package/dist/flags/evaluator.js.map +1 -0
  53. package/dist/flags/flag.d.ts +199 -0
  54. package/dist/flags/flag.d.ts.map +1 -0
  55. package/dist/flags/flag.js +337 -0
  56. package/dist/flags/flag.js.map +1 -0
  57. package/dist/flags/identity.d.ts +77 -0
  58. package/dist/flags/identity.d.ts.map +1 -0
  59. package/dist/flags/identity.js +134 -0
  60. package/dist/flags/identity.js.map +1 -0
  61. package/dist/flags/index.d.ts +20 -0
  62. package/dist/flags/index.d.ts.map +1 -0
  63. package/dist/flags/index.js +21 -0
  64. package/dist/flags/index.js.map +1 -0
  65. package/dist/flags/operators.d.ts +63 -0
  66. package/dist/flags/operators.d.ts.map +1 -0
  67. package/dist/flags/operators.js +299 -0
  68. package/dist/flags/operators.js.map +1 -0
  69. package/dist/flags/propagation.d.ts +16 -0
  70. package/dist/flags/propagation.d.ts.map +1 -0
  71. package/dist/flags/propagation.js +107 -0
  72. package/dist/flags/propagation.js.map +1 -0
  73. package/dist/flags/realtime.d.ts +353 -0
  74. package/dist/flags/realtime.d.ts.map +1 -0
  75. package/dist/flags/realtime.js +779 -0
  76. package/dist/flags/realtime.js.map +1 -0
  77. package/dist/flags/runtime-mode.d.ts +32 -0
  78. package/dist/flags/runtime-mode.d.ts.map +1 -0
  79. package/dist/flags/runtime-mode.js +84 -0
  80. package/dist/flags/runtime-mode.js.map +1 -0
  81. package/dist/flags/telemetry.d.ts +79 -0
  82. package/dist/flags/telemetry.d.ts.map +1 -0
  83. package/dist/flags/telemetry.js +286 -0
  84. package/dist/flags/telemetry.js.map +1 -0
  85. package/dist/http.d.ts +4 -0
  86. package/dist/http.d.ts.map +1 -1
  87. package/dist/http.js +44 -12
  88. package/dist/http.js.map +1 -1
  89. package/dist/index.d.ts +14 -2
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +16 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/management-types.d.ts +38 -0
  94. package/dist/management-types.d.ts.map +1 -1
  95. package/dist/route-guard.d.ts.map +1 -1
  96. package/dist/route-guard.js +15 -5
  97. package/dist/route-guard.js.map +1 -1
  98. package/dist/token-manager.d.ts +3 -1
  99. package/dist/token-manager.d.ts.map +1 -1
  100. package/dist/token-manager.js +10 -6
  101. package/dist/token-manager.js.map +1 -1
  102. package/dist/types.d.ts +10 -2
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/usage/index.d.ts +3 -0
  105. package/dist/usage/index.d.ts.map +1 -0
  106. package/dist/usage/index.js +4 -0
  107. package/dist/usage/index.js.map +1 -0
  108. package/dist/usage/storage/durable-storage.d.ts +47 -0
  109. package/dist/usage/storage/durable-storage.d.ts.map +1 -0
  110. package/dist/usage/storage/durable-storage.js +27 -0
  111. package/dist/usage/storage/durable-storage.js.map +1 -0
  112. package/dist/usage/storage/index.d.ts +7 -0
  113. package/dist/usage/storage/index.d.ts.map +1 -0
  114. package/dist/usage/storage/index.js +28 -0
  115. package/dist/usage/storage/index.js.map +1 -0
  116. package/dist/usage/storage/indexeddb-storage.d.ts +18 -0
  117. package/dist/usage/storage/indexeddb-storage.d.ts.map +1 -0
  118. package/dist/usage/storage/indexeddb-storage.js +159 -0
  119. package/dist/usage/storage/indexeddb-storage.js.map +1 -0
  120. package/dist/usage/storage/node-fs-storage.d.ts +29 -0
  121. package/dist/usage/storage/node-fs-storage.d.ts.map +1 -0
  122. package/dist/usage/storage/node-fs-storage.js +180 -0
  123. package/dist/usage/storage/node-fs-storage.js.map +1 -0
  124. package/dist/usage/storage/noop-storage.d.ts +17 -0
  125. package/dist/usage/storage/noop-storage.d.ts.map +1 -0
  126. package/dist/usage/storage/noop-storage.js +67 -0
  127. package/dist/usage/storage/noop-storage.js.map +1 -0
  128. package/dist/usage/usage-reporter.d.ts +64 -0
  129. package/dist/usage/usage-reporter.d.ts.map +1 -0
  130. package/dist/usage/usage-reporter.js +234 -0
  131. package/dist/usage/usage-reporter.js.map +1 -0
  132. package/package.json +2 -1
@@ -0,0 +1,779 @@
1
+ // SDK realtime client (TBP-150).
2
+ //
3
+ // Auto-discovers the workspace's pub/sub protocol from `GET /realtime/config`
4
+ // (TBP-147), authorizes via `POST /realtime/authorize` (TBP-151 — Centrifugo
5
+ // path only; AppSync uses a Lambda authorizer server-side), and connects to
6
+ // receive live flag updates + per-user identity changes.
7
+ //
8
+ // Two protocols, one shape:
9
+ // - `centrifugo`: WebSocket to a Centrifugo server using a signed connect token.
10
+ // - `appsync`: WebSocket to AWS AppSync Events (TBP-148). Client carries
11
+ // its Bridge JWT directly in the `header-…` subprotocol; the
12
+ // Lambda authorizer makes per-channel decisions server-side
13
+ // (no `/realtime/authorize` round-trip).
14
+ //
15
+ // `noop` server-side → realtime is disabled; the SDK falls back to periodic
16
+ // poll or simply doesn't get live updates.
17
+ //
18
+ // Messages received on the workspace channel update the BridgeFlags cache
19
+ // (`upsert` / `remove`). Per-user channel messages are handled via callbacks
20
+ // the framework SDK supplies (token refresh, attribute changes — see TBP-90).
21
+ export class RealtimeClient {
22
+ cfg;
23
+ ws;
24
+ state = 'idle';
25
+ reconnectDelayMs;
26
+ reconnectTimer;
27
+ bridge;
28
+ onUserStateHook;
29
+ onSubscriptionPlanChangedHook;
30
+ onBillingLifecycleHook;
31
+ onQuotaUpdatedHook;
32
+ onEntitlementsChangedHook;
33
+ // Phase 3 (TBP-287/314) — fans `session.snapshot` out to whichever slices
34
+ // the consumer has wired (app.branding, tenant.subscription, etc.).
35
+ onSnapshotHook;
36
+ onOpenHook;
37
+ onCloseHook;
38
+ stopped = false;
39
+ constructor(cfg) {
40
+ const defaultWs = ((url, protocols) =>
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ new globalThis.WebSocket(url, protocols));
43
+ this.cfg = {
44
+ apiBaseUrl: cfg.apiBaseUrl.replace(/\/+$/, ''),
45
+ apiKey: cfg.apiKey,
46
+ appId: cfg.appId,
47
+ workspaceId: cfg.workspaceId,
48
+ userId: cfg.userId,
49
+ enabled: cfg.enabled !== false,
50
+ reconnectBaseMs: cfg.reconnectBaseMs ?? 1000,
51
+ reconnectMaxMs: cfg.reconnectMaxMs ?? 30_000,
52
+ websocketFactory: cfg.websocketFactory ?? defaultWs,
53
+ fetchFn: cfg.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined),
54
+ getAuthToken: cfg.getAuthToken,
55
+ };
56
+ this.reconnectDelayMs = this.cfg.reconnectBaseMs;
57
+ }
58
+ /** Attach to a BridgeFlags instance — flag updates auto-apply to its cache. */
59
+ attach(bridge) {
60
+ this.bridge = bridge;
61
+ }
62
+ /** Register a hook for per-user channel messages. */
63
+ setOnUserState(hook) {
64
+ this.onUserStateHook = hook;
65
+ }
66
+ /**
67
+ * Billing 2.0 US-3 — register a hook for canonical subscription plan-change
68
+ * events. The billing reactive surface (`BridgeSubscription.attach(rt)`)
69
+ * wires this up; framework SDKs typically don't call it directly.
70
+ */
71
+ setOnSubscriptionPlanChanged(hook) {
72
+ this.onSubscriptionPlanChangedHook = hook;
73
+ }
74
+ /**
75
+ * Billing 2.0 US-5+ — register a hook for all canonical lifecycle events
76
+ * (payment.*, subscription.*, dunning.*, entitlements.*). `BridgeSubscription.attach(rt)`
77
+ * wires this; user-level event handlers can also register here via
78
+ * `useBridge().handle({ "payment.failed": ... })`.
79
+ */
80
+ setOnBillingLifecycle(hook) {
81
+ this.onBillingLifecycleHook = hook;
82
+ }
83
+ /**
84
+ * Billing 2.0 US-11 — register a hook for `quota.updated` payloads on the
85
+ * workspace channel. `useBridge().quota(metric)` consumers wire this up
86
+ * so live counter UI reflects server-side ingest without polling.
87
+ */
88
+ setOnQuotaUpdated(hook) {
89
+ this.onQuotaUpdatedHook = hook;
90
+ }
91
+ /**
92
+ * Billing 2.0 US-12 — register a hook for `entitlements.changed` payloads
93
+ * on the workspace channel that carry the full entitlements map.
94
+ * `useBridge().entitlements.can(...)` consumers wire this up so the cache
95
+ * replaces wholesale on every diff.
96
+ *
97
+ * Note: the legacy `BillingLifecycleMessage` `entitlements.changed` kind
98
+ * (signal-only, no payload) keeps firing through `setOnBillingLifecycle`
99
+ * — this hook ONLY fires when the wire payload includes the `entitlements`
100
+ * field. Allows both old and new consumers to coexist during rollout.
101
+ */
102
+ setOnEntitlementsChanged(hook) {
103
+ this.onEntitlementsChangedHook = hook;
104
+ }
105
+ /**
106
+ * Phase 3 (TBP-287/314) — register a hook for `session.snapshot`. The server
107
+ * publishes one on every successful per-user channel subscribe (initial
108
+ * connect AND reconnect). Framework SDKs use this to pre-populate the
109
+ * `bridge.app.branding` / `bridge.tenant.{subscription,entitlements}` /
110
+ * `bridge.user` slices on first paint, eliminating the per-slice REST
111
+ * hydrate round-trips that the legacy bootstrap path required.
112
+ *
113
+ * Composition is fixed (no consumer config). If a later release promotes
114
+ * another slice into the snapshot, that slice's `.load()` becomes a no-op
115
+ * automatically — consumers don't need code changes.
116
+ */
117
+ setOnSnapshot(hook) {
118
+ this.onSnapshotHook = hook;
119
+ }
120
+ /**
121
+ * Register a hook fired when the WebSocket transitions to `'open'` —
122
+ * fires on initial connect AND on every successful reconnect. Framework
123
+ * SDKs use this to re-fire startup tasks (e.g. cache hydration) that
124
+ * may have been missed during an outage.
125
+ */
126
+ setOnOpen(hook) {
127
+ this.onOpenHook = hook;
128
+ }
129
+ /**
130
+ * Register a hook fired when the WebSocket transitions to `'closed'` —
131
+ * use for surfacing connection status to consumers (e.g. an "offline"
132
+ * indicator). Fires on intentional close as well; check `getState()`
133
+ * if you need to distinguish.
134
+ */
135
+ setOnClose(hook) {
136
+ this.onCloseHook = hook;
137
+ }
138
+ /**
139
+ * Re-run the authorize step against the current `getAuthToken()` value and
140
+ * re-open the WebSocket. Used by the framework SDK on every token rotation
141
+ * where the userId is unchanged but the JWT value rotated (post-refresh).
142
+ *
143
+ * Without this, the existing connection keeps riding the OLD token until
144
+ * Centrifugo's own connection-token TTL drops it — a strictly-larger
145
+ * blast-radius window than necessary.
146
+ *
147
+ * Behavior:
148
+ * - Disabled / stopped → no-op.
149
+ * - Mid-`connecting` → no-op; the in-flight authorize() reads the current
150
+ * token by closure, so it will already pick up the new value.
151
+ * - Open → drop the ws ref (so the old socket's onclose treats itself as
152
+ * stale via the identity guard in `openWebSocket`), close it with
153
+ * `1000 / sdk.reauthorize`, reset backoff, then `start()` immediately.
154
+ */
155
+ async reauthorize() {
156
+ if (!this.cfg.enabled || this.stopped)
157
+ return;
158
+ if (this.state === 'connecting')
159
+ return;
160
+ if (this.reconnectTimer) {
161
+ clearTimeout(this.reconnectTimer);
162
+ this.reconnectTimer = undefined;
163
+ }
164
+ this.reconnectDelayMs = this.cfg.reconnectBaseMs;
165
+ if (this.state === 'open' && this.ws) {
166
+ const oldWs = this.ws;
167
+ this.ws = undefined;
168
+ this.state = 'closed';
169
+ try {
170
+ oldWs.close(1000, 'sdk.reauthorize');
171
+ }
172
+ catch {
173
+ // ignore
174
+ }
175
+ }
176
+ await this.start();
177
+ }
178
+ /**
179
+ * Phase 2 (TBP-307) — Set/update the appId after initial start. Framework
180
+ * SDKs call this when the app context first lands (e.g. after the first
181
+ * authorize round-trip exposes the JWT `aid` claim). Triggers a reconnect
182
+ * so the new `app:<appId>` channel is included in the next authorize.
183
+ */
184
+ setAppId(appId) {
185
+ if (this.cfg.appId === appId)
186
+ return;
187
+ this.cfg.appId = appId;
188
+ if (this.state === 'open' && this.ws) {
189
+ this.ws.close(1000, 'sdk.setAppId');
190
+ // onclose → scheduleReconnect → start() picks up updated channelsToSubscribe()
191
+ }
192
+ }
193
+ /**
194
+ * Phase 2 (TBP-307) — Set/update the workspaceId after initial start. Used
195
+ * by framework SDKs when the tenant context lands or changes (workspace
196
+ * switcher, tenant join/leave). Triggers a reconnect.
197
+ */
198
+ setWorkspaceId(workspaceId) {
199
+ if (this.cfg.workspaceId === workspaceId)
200
+ return;
201
+ this.cfg.workspaceId = workspaceId;
202
+ if (this.state === 'open' && this.ws) {
203
+ this.ws.close(1000, 'sdk.setWorkspaceId');
204
+ }
205
+ }
206
+ /**
207
+ * Update the userId after initial start — used by the framework SDK to
208
+ * subscribe to the per-user channel when the user logs in post-bootstrap.
209
+ * Triggers a reconnect so the new channel is included in the next authorize.
210
+ */
211
+ setUserId(userId) {
212
+ if (this.cfg.userId === userId)
213
+ return;
214
+ this.cfg.userId = userId;
215
+ if (this.state === 'open' && this.ws) {
216
+ this.ws.close(1000, 'sdk.setUserId');
217
+ // onclose fires → scheduleReconnect → start() picks up updated channelsToSubscribe()
218
+ }
219
+ }
220
+ /** Begin connecting. Idempotent. */
221
+ async start() {
222
+ if (!this.cfg.enabled || this.stopped)
223
+ return;
224
+ if (this.state !== 'idle' && this.state !== 'closed')
225
+ return;
226
+ this.state = 'connecting';
227
+ try {
228
+ const serverConfig = await this.fetchServerConfig();
229
+ if (serverConfig.kind === 'noop' || !serverConfig.endpoint) {
230
+ this.state = 'closed';
231
+ return;
232
+ }
233
+ const channels = this.channelsToSubscribe();
234
+ if (serverConfig.kind === 'appsync') {
235
+ // AppSync uses a Lambda authorizer for per-channel auth — no client-side
236
+ // /realtime/authorize round-trip. The Bridge JWT is carried in the
237
+ // subprotocol negotiation; the Lambda decides allow/deny per channel.
238
+ this.openAppSyncWebSocket(serverConfig.endpoint, channels);
239
+ return;
240
+ }
241
+ if (serverConfig.kind === 'centrifugo') {
242
+ const auth = await this.authorize(channels);
243
+ this.openWebSocket(serverConfig.endpoint, auth);
244
+ return;
245
+ }
246
+ // Unknown protocol — close cleanly so consumers don't get stuck in
247
+ // 'connecting'. New transports must be added explicitly here.
248
+ this.state = 'closed';
249
+ }
250
+ catch {
251
+ this.state = 'closed';
252
+ this.scheduleReconnect();
253
+ }
254
+ }
255
+ /** Close the connection. Idempotent. */
256
+ async stop() {
257
+ this.stopped = true;
258
+ if (this.reconnectTimer) {
259
+ clearTimeout(this.reconnectTimer);
260
+ this.reconnectTimer = undefined;
261
+ }
262
+ if (this.ws) {
263
+ try {
264
+ this.ws.close(1000, 'sdk.stop');
265
+ }
266
+ catch {
267
+ // ignore
268
+ }
269
+ this.ws = undefined;
270
+ }
271
+ this.state = 'closed';
272
+ }
273
+ /** Read connection state. */
274
+ getState() {
275
+ return this.state;
276
+ }
277
+ /**
278
+ * Channels this client subscribes to — the three canonical channels:
279
+ * - `app:<appId>` — flag mutations, app config (app-scoped)
280
+ * - `workspace:<wsId>` — subscription, quota, entitlement (tenant-scoped)
281
+ * - `user:<userId>` — user-state, role/attr change (user-scoped)
282
+ *
283
+ * Each id is optional; the SDK skips the channel if its id isn't configured.
284
+ * The anonymous-only standalone case ends up with just `app:<appId>`.
285
+ */
286
+ channelsToSubscribe() {
287
+ const out = [];
288
+ if (this.cfg.appId)
289
+ out.push(`app:${this.cfg.appId}`);
290
+ if (this.cfg.workspaceId)
291
+ out.push(`workspace:${this.cfg.workspaceId}`);
292
+ if (this.cfg.userId)
293
+ out.push(`user:${this.cfg.userId}`);
294
+ return out;
295
+ }
296
+ // ── private ───────────────────────────────────────────────────────────────
297
+ async fetchServerConfig() {
298
+ const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/config`, {
299
+ method: 'GET',
300
+ headers: { 'x-api-key': this.cfg.apiKey },
301
+ });
302
+ if (!res.ok) {
303
+ throw new Error(`realtime config fetch failed: ${res.status}`);
304
+ }
305
+ return (await res.json());
306
+ }
307
+ async authorize(channels) {
308
+ const token = this.cfg.getAuthToken?.() ?? this.cfg.apiKey;
309
+ const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/authorize`, {
310
+ method: 'POST',
311
+ headers: {
312
+ Authorization: `Bearer ${token}`,
313
+ 'Content-Type': 'application/json',
314
+ },
315
+ body: JSON.stringify({ channels }),
316
+ });
317
+ if (!res.ok) {
318
+ throw new Error(`realtime authorize failed: ${res.status}`);
319
+ }
320
+ return (await res.json());
321
+ }
322
+ openWebSocket(endpoint, auth) {
323
+ const ws = this.cfg.websocketFactory(endpoint);
324
+ this.ws = ws;
325
+ ws.onopen = () => {
326
+ if (this.ws !== ws)
327
+ return;
328
+ this.state = 'open';
329
+ this.reconnectDelayMs = this.cfg.reconnectBaseMs;
330
+ // Send connect with the signed token + channels. Centrifugo expects a
331
+ // command frame like `{ "connect": { "token": "..." }, "id": 1 }` and
332
+ // separate subscribe frames per channel. For v1 we send one connect
333
+ // and let the server-side token's `channels` claim handle subscription.
334
+ try {
335
+ ws.send(JSON.stringify({ id: 1, connect: { token: auth.signedToken } }));
336
+ }
337
+ catch {
338
+ // ignore
339
+ }
340
+ try {
341
+ this.onOpenHook?.();
342
+ }
343
+ catch {
344
+ // hook errors must not break the connection
345
+ }
346
+ };
347
+ ws.onmessage = (ev) => {
348
+ if (this.ws !== ws)
349
+ return;
350
+ // Centrifugo v5 JSON protocol keepalive: the server periodically sends
351
+ // an empty `{}` frame and expects an empty `{}` reply. Without this
352
+ // echo the server closes the connection on its pong-timeout and the
353
+ // SDK ends up in a perpetual reconnect loop.
354
+ if (typeof ev.data === 'string' && ev.data === '{}') {
355
+ try {
356
+ ws.send('{}');
357
+ }
358
+ catch {
359
+ // ignore — onclose will pick up a broken socket
360
+ }
361
+ return;
362
+ }
363
+ try {
364
+ const parsed = parseMessage(ev.data);
365
+ if (parsed)
366
+ this.handleMessage(parsed);
367
+ }
368
+ catch {
369
+ // ignore malformed
370
+ }
371
+ };
372
+ ws.onclose = () => {
373
+ // Identity guard — if we've already replaced this ws (e.g. via
374
+ // reauthorize() dropping the ref before close), the late-firing onclose
375
+ // is from a stale socket. Don't flap state or fire hooks.
376
+ if (this.ws !== ws)
377
+ return;
378
+ this.state = 'closed';
379
+ try {
380
+ this.onCloseHook?.();
381
+ }
382
+ catch {
383
+ // hook errors must not block reconnect scheduling
384
+ }
385
+ this.scheduleReconnect();
386
+ };
387
+ ws.onerror = () => {
388
+ // Let onclose handle reconnect — errors are noisy but not actionable.
389
+ };
390
+ }
391
+ /**
392
+ * TBP-148 — AppSync Events transport.
393
+ *
394
+ * Wire protocol (AWS public spec for AppSync Events, distinct from the older
395
+ * AppSync GraphQL `graphql-ws` subscriptions):
396
+ * - WebSocket to `wss://<endpoint>/event/realtime` (path appended if the
397
+ * endpoint from /realtime/config doesn't already include it — stage
398
+ * CFN output is the bare host today; the spec test uses the full URL).
399
+ * - Subprotocols: `['aws-appsync-event-ws', 'header-<base64url-json>']`.
400
+ * The `header-…` token carries auth, since browsers can't set arbitrary
401
+ * HTTP headers on a WebSocket.
402
+ * - After open: `{type:'connection_init'}` → server replies
403
+ * `{type:'connection_ack', connectionTimeoutMs}` → then one
404
+ * `{type:'subscribe', id, channel, authorization}` per channel.
405
+ * - Data: `{type:'data', id, event:'<json-string>'}` — `event` is a string
406
+ * (matches `appsync-events.adapter.ts:90` JSON.stringify).
407
+ * - Keepalive: server sends `{type:'ka'}` (silently ignored).
408
+ * - Channel wire format: `<ns>/<id>` (colon-to-slash; mirrors
409
+ * `appsync-events.adapter.ts:87` and `appsync-authorizer.handler.ts:59`).
410
+ *
411
+ * Anonymous flow: `getAuthToken()` returns undefined → Authorization sent as
412
+ * empty string. The Lambda authorizer accepts that only for `app:<appId>`
413
+ * channels whose origin matches the app's allowedOrigins (demo path).
414
+ */
415
+ openAppSyncWebSocket(endpoint, channels) {
416
+ const { url, httpHost } = normalizeAppSyncEndpoint(endpoint);
417
+ // AWS spec: the auth header's `host` field refers to the HTTP endpoint
418
+ // even when the wss:// call is made against the realtime endpoint. The
419
+ // server-side validation uses this to verify the connection — sending
420
+ // the realtime host instead produces a silent close 1000 right after
421
+ // the WS upgrade succeeds.
422
+ const authHeader = buildAppSyncAuthHeader(this.cfg.getAuthToken?.(), httpHost);
423
+ const headerProtocol = `header-${base64urlEncode(JSON.stringify(authHeader))}`;
424
+ const ws = this.cfg.websocketFactory(url, [APPSYNC_WS_PROTOCOL, headerProtocol]);
425
+ this.ws = ws;
426
+ ws.onopen = () => {
427
+ if (this.ws !== ws)
428
+ return;
429
+ // `state` stays 'connecting' until connection_ack lands — premature
430
+ // transition would let the client miss server-side rejects (auth
431
+ // failure surfaces as a quick close right after open).
432
+ try {
433
+ ws.send(JSON.stringify({ type: 'connection_init' }));
434
+ }
435
+ catch {
436
+ // onclose will fire on a broken socket; no further work here.
437
+ }
438
+ };
439
+ ws.onmessage = (ev) => {
440
+ if (this.ws !== ws)
441
+ return;
442
+ let frame;
443
+ try {
444
+ frame = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
445
+ }
446
+ catch {
447
+ return; // malformed — ignore
448
+ }
449
+ if (!frame || typeof frame !== 'object')
450
+ return;
451
+ const type = typeof frame.type === 'string' ? frame.type : '';
452
+ switch (type) {
453
+ case 'connection_ack': {
454
+ // Handshake complete — open the gate for app traffic.
455
+ this.state = 'open';
456
+ this.reconnectDelayMs = this.cfg.reconnectBaseMs;
457
+ for (const channel of channels) {
458
+ try {
459
+ ws.send(JSON.stringify({
460
+ type: 'subscribe',
461
+ id: appSyncSubscriptionId(),
462
+ channel: appSyncChannelToWire(channel),
463
+ authorization: authHeader,
464
+ }));
465
+ }
466
+ catch {
467
+ // ignore — onclose will pick up a broken socket.
468
+ }
469
+ }
470
+ try {
471
+ this.onOpenHook?.();
472
+ }
473
+ catch {
474
+ // hook errors must not break the connection.
475
+ }
476
+ break;
477
+ }
478
+ case 'ka':
479
+ // Keepalive — server-initiated, no client response required.
480
+ break;
481
+ case 'data': {
482
+ // Per AWS spec, the `event` field is an **array of stringified JSON
483
+ // values** (publish accepts `events: [...]`; data delivers `event:
484
+ // [...]`). The backend currently publishes one entry per frame
485
+ // (`events: [JSON.stringify(payload)]`), but the wire shape is an
486
+ // array either way — iterate, decode each, dispatch independently.
487
+ const rawList = Array.isArray(frame.event)
488
+ ? frame.event
489
+ : typeof frame.event === 'string'
490
+ ? [frame.event]
491
+ : [];
492
+ for (const raw of rawList) {
493
+ if (typeof raw !== 'string')
494
+ continue;
495
+ let payload;
496
+ try {
497
+ payload = JSON.parse(raw);
498
+ }
499
+ catch {
500
+ continue;
501
+ }
502
+ if (payload &&
503
+ typeof payload === 'object' &&
504
+ typeof payload.kind === 'string') {
505
+ try {
506
+ this.handleMessage(payload);
507
+ }
508
+ catch {
509
+ // hook errors are isolated per-handler in handleMessage.
510
+ }
511
+ }
512
+ }
513
+ break;
514
+ }
515
+ case 'subscribe_success':
516
+ // Per-channel ack — no action required, server now streams data.
517
+ break;
518
+ case 'connection_error':
519
+ case 'subscribe_error':
520
+ case 'error':
521
+ // Server-side reject — close cleanly so onclose triggers reconnect.
522
+ // We intentionally don't surface the error message: the next
523
+ // connect's authorize will succeed or fail with the same signal.
524
+ try {
525
+ ws.close(1011, `appsync:${type}`);
526
+ }
527
+ catch {
528
+ // ignore
529
+ }
530
+ break;
531
+ default:
532
+ // Unknown frame type — ignore. Forward-compatible with future
533
+ // protocol additions (e.g. `keepalive`, `pong`, …).
534
+ break;
535
+ }
536
+ };
537
+ ws.onclose = () => {
538
+ if (this.ws !== ws)
539
+ return;
540
+ this.state = 'closed';
541
+ try {
542
+ this.onCloseHook?.();
543
+ }
544
+ catch {
545
+ // hook errors must not block reconnect scheduling.
546
+ }
547
+ this.scheduleReconnect();
548
+ };
549
+ ws.onerror = () => {
550
+ // Let onclose handle reconnect — errors are noisy but not actionable.
551
+ };
552
+ }
553
+ handleMessage(msg) {
554
+ switch (msg.kind) {
555
+ case 'flag.updated':
556
+ if (this.bridge && msg.flag)
557
+ this.bridge.upsert(msg.flag);
558
+ break;
559
+ case 'flag.removed':
560
+ if (this.bridge && msg.key)
561
+ this.bridge.remove(msg.key);
562
+ break;
563
+ case 'user.state_changed':
564
+ if (this.onUserStateHook) {
565
+ try {
566
+ this.onUserStateHook(msg);
567
+ }
568
+ catch {
569
+ // ignore
570
+ }
571
+ }
572
+ break;
573
+ case 'subscription.plan_changed':
574
+ if (this.onSubscriptionPlanChangedHook) {
575
+ try {
576
+ this.onSubscriptionPlanChangedHook(msg);
577
+ }
578
+ catch {
579
+ // ignore
580
+ }
581
+ }
582
+ break;
583
+ case 'payment.failed':
584
+ case 'payment.succeeded':
585
+ case 'subscription.created':
586
+ case 'subscription.updated':
587
+ case 'subscription.canceled':
588
+ case 'subscription.reactivated':
589
+ case 'subscription.trial_started':
590
+ case 'subscription.trial_ending_soon':
591
+ case 'subscription.trial_converted':
592
+ case 'subscription.trial_expired':
593
+ case 'dunning.entered':
594
+ case 'dunning.retry_scheduled':
595
+ case 'dunning.recovered':
596
+ case 'dunning.exhausted':
597
+ if (this.onBillingLifecycleHook) {
598
+ try {
599
+ this.onBillingLifecycleHook(msg);
600
+ }
601
+ catch {
602
+ // ignore
603
+ }
604
+ }
605
+ break;
606
+ case 'entitlements.changed':
607
+ // bridge-api always publishes the payload-carrying shape (US-12);
608
+ // the pre-prod signal-only fallback was removed at milestone close-out.
609
+ if (this.onEntitlementsChangedHook) {
610
+ try {
611
+ this.onEntitlementsChangedHook(msg);
612
+ }
613
+ catch {
614
+ // ignore
615
+ }
616
+ }
617
+ break;
618
+ case 'quota.updated':
619
+ if (this.onQuotaUpdatedHook) {
620
+ try {
621
+ this.onQuotaUpdatedHook(msg);
622
+ }
623
+ catch {
624
+ // ignore
625
+ }
626
+ }
627
+ break;
628
+ case 'session.snapshot':
629
+ // Phase 3 (TBP-287/314) — first-paint snapshot. Defensive check on
630
+ // `data` because the wire shape is deeper than the other kinds and
631
+ // a partial server might omit it; we never want to call the hook
632
+ // with `undefined`.
633
+ if (this.onSnapshotHook && msg.data) {
634
+ try {
635
+ this.onSnapshotHook(msg);
636
+ }
637
+ catch {
638
+ // ignore
639
+ }
640
+ }
641
+ break;
642
+ }
643
+ }
644
+ scheduleReconnect() {
645
+ if (this.stopped)
646
+ return;
647
+ if (this.reconnectTimer)
648
+ return;
649
+ this.reconnectTimer = setTimeout(() => {
650
+ this.reconnectTimer = undefined;
651
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
652
+ void this.start();
653
+ }, this.reconnectDelayMs);
654
+ if (this.reconnectTimer?.unref)
655
+ this.reconnectTimer.unref();
656
+ }
657
+ }
658
+ function parseMessage(raw) {
659
+ if (typeof raw !== 'string')
660
+ return null;
661
+ let parsed;
662
+ try {
663
+ parsed = JSON.parse(raw);
664
+ }
665
+ catch {
666
+ return null;
667
+ }
668
+ // Centrifugo wraps publish data in `{ push: { channel, pub: { data: {...} } } }`.
669
+ // We accept both that shape and a flat `{ kind: ... }` shape so the same
670
+ // client works with simpler transports too.
671
+ const data = parsed?.push?.pub?.data ??
672
+ parsed?.pub?.data ??
673
+ parsed;
674
+ if (!data || typeof data !== 'object' || typeof data.kind !== 'string')
675
+ return null;
676
+ return data;
677
+ }
678
+ // ── AppSync Events helpers (TBP-148) ─────────────────────────────────────────
679
+ /** Subprotocol identifier for AppSync Events realtime channels. */
680
+ const APPSYNC_WS_PROTOCOL = 'aws-appsync-event-ws';
681
+ /**
682
+ * Normalize the realtime endpoint to a full `wss://…/event/realtime` URL and
683
+ * compute the matching HTTP host (needed in the auth header — AppSync
684
+ * server-side validation uses the HTTP host, NOT the realtime host).
685
+ *
686
+ * Stage's CFN output currently surfaces a bare host (`<id>.appsync-realtime-api.<region>.amazonaws.com`),
687
+ * while the backend spec tests use the fully-qualified `wss://…/event/realtime`.
688
+ * Both must work — this normalizer accepts either.
689
+ *
690
+ * HTTP-host derivation: AWS uses two parallel domains for AppSync Events:
691
+ * wss://<id>.appsync-realtime-api.<region>.amazonaws.com/event/realtime
692
+ * https://<id>.appsync-api.<region>.amazonaws.com/event
693
+ * The HTTP host is the realtime host with `appsync-realtime-api` swapped for
694
+ * `appsync-api`. Custom domains skip the suffix entirely (host == http host).
695
+ */
696
+ function normalizeAppSyncEndpoint(endpoint) {
697
+ let raw = endpoint.trim();
698
+ if (!raw.startsWith('ws://') && !raw.startsWith('wss://')) {
699
+ raw = `wss://${raw}`;
700
+ }
701
+ raw = raw.replace(/\/+$/, '');
702
+ if (!/\/event\/realtime$/.test(raw)) {
703
+ raw = `${raw}/event/realtime`;
704
+ }
705
+ let realtimeHost = '';
706
+ try {
707
+ realtimeHost = new URL(raw).host;
708
+ }
709
+ catch {
710
+ realtimeHost = endpoint.replace(/^wss?:\/\//, '').split('/')[0] ?? '';
711
+ }
712
+ // Swap the realtime suffix → http suffix. If the host doesn't match the
713
+ // standard AppSync naming (e.g. custom domain), pass it through unchanged
714
+ // — the same host serves both endpoints on custom domains.
715
+ const httpHost = realtimeHost.replace('.appsync-realtime-api.', '.appsync-api.');
716
+ return { url: raw, httpHost };
717
+ }
718
+ /**
719
+ * Build the AppSync Events auth header carried in the `header-…` subprotocol
720
+ * token. Anonymous sessions send Authorization: '' — the Lambda authorizer
721
+ * accepts that only for `app:<appId>` channels with a passing origin check.
722
+ */
723
+ function buildAppSyncAuthHeader(token, host) {
724
+ return {
725
+ Authorization: token ? `Bearer ${token}` : '',
726
+ host,
727
+ };
728
+ }
729
+ /**
730
+ * Base64url-encode a UTF-8 string. AppSync Events expects the `header-…`
731
+ * subprotocol token to be base64url (no padding). Uses `btoa` when available
732
+ * (every modern browser + Node ≥16 globalThis); falls back to a manual encode
733
+ * for the rare environment where it isn't.
734
+ */
735
+ function base64urlEncode(input) {
736
+ let b64;
737
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
738
+ const g = globalThis;
739
+ if (typeof g.btoa === 'function') {
740
+ // btoa wants binary string; encode UTF-8 → bytes first so non-ASCII JWTs
741
+ // survive (rare but legal — JWT header/claims can be unicode).
742
+ const utf8 = unescape(encodeURIComponent(input));
743
+ b64 = g.btoa(utf8);
744
+ }
745
+ else if (typeof g.Buffer?.from === 'function') {
746
+ b64 = g.Buffer.from(input, 'utf-8').toString('base64');
747
+ }
748
+ else {
749
+ // No encoder available — return the raw input. Will fail the handshake,
750
+ // but loudly (Lambda authorizer rejects), which is preferable to silent
751
+ // corruption.
752
+ return input;
753
+ }
754
+ return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
755
+ }
756
+ /**
757
+ * Translate internal channel name (`<ns>:<id>`) to the AppSync wire form
758
+ * (`<ns>/<id>`). Mirrors the publish side at
759
+ * `microservices/shared/realtime/adapters/appsync-events.adapter.ts:87`
760
+ * and the Lambda authorizer's normalizer at
761
+ * `microservices/shared/realtime/appsync-authorizer.handler.ts:59`.
762
+ */
763
+ function appSyncChannelToWire(internal) {
764
+ return internal.replace(':', '/');
765
+ }
766
+ /**
767
+ * Stable per-subscribe identifier. Prefer `crypto.randomUUID()` (ES2022,
768
+ * available in every supported runtime — browser globals + Node ≥19); fall
769
+ * back to a Math.random-based id for the rare environment where it isn't.
770
+ */
771
+ function appSyncSubscriptionId() {
772
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
773
+ const g = globalThis;
774
+ if (g.crypto?.randomUUID) {
775
+ return g.crypto.randomUUID();
776
+ }
777
+ return `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
778
+ }
779
+ //# sourceMappingURL=realtime.js.map