@nebulr-group/bridge-auth-core 0.6.0 → 0.7.0-beta.0

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.
@@ -19,8 +19,34 @@
19
19
  // (`upsert` / `remove`). Per-user channel messages are handled via callbacks
20
20
  // the framework SDK supplies (token refresh, attribute changes — see TBP-90).
21
21
  import { createLogger } from '../logger.js';
22
+ // TBP-643 — single place to repoint the docs the terminal messages link to.
23
+ // Links render as `<base>#<reason>`, so every reason code we emit is an anchor
24
+ // on that page — keep codes lowercase_with_underscores.
25
+ export const REALTIME_DOCS_BASE_URL = 'https://thebridge.dev/docs/live-updates/troubleshooting/';
26
+ /**
27
+ * The AppSync `Authorization` value a signed-out session presents. Must be
28
+ * non-empty (AppSync rejects '' before the authorizer runs) and must match
29
+ * what the bridge-api authorizer recognises as "no token" byte for byte.
30
+ */
31
+ export const REALTIME_ANONYMOUS_TOKEN = 'anonymous';
22
32
  /** How long to wait for a subscribe ack before declaring the connection deaf. */
23
33
  const SUBSCRIBE_ACK_TIMEOUT_MS = 10_000;
34
+ /** Cap on the diagnose round-trip — a hung call must not leave us 'connecting' forever. */
35
+ const DIAGNOSE_TIMEOUT_MS = 5_000;
36
+ /** While parked in 'unauthorized', how often to look for a new token. */
37
+ const PARKED_TOKEN_CHECK_MS = 5_000;
38
+ /** Healthy connections are reported at most this often (TBP-645). */
39
+ const STATUS_REPORT_OPEN_INTERVAL_MS = 10 * 60_000;
40
+ /** Cap on a status report — it is telemetry, it must not linger. */
41
+ const STATUS_REPORT_TIMEOUT_MS = 3_000;
42
+ /** Centrifugo `/realtime/authorize` answered 401/403 — a refusal, not a blip. */
43
+ class RealtimeAuthRefusedError extends Error {
44
+ status;
45
+ constructor(status) {
46
+ super(`realtime authorize refused: ${status}`);
47
+ this.status = status;
48
+ }
49
+ }
24
50
  export class RealtimeClient {
25
51
  cfg;
26
52
  ws;
@@ -55,6 +81,33 @@ export class RealtimeClient {
55
81
  ackedChannels = new Set();
56
82
  failedChannels = new Map();
57
83
  subscribeAckTimer;
84
+ // ── Fault reporting (TBP-643) ─────────────────────────────────────────────
85
+ status;
86
+ onStatusChangeHook;
87
+ /** The current run of trouble, if any — see FaultEpisode. */
88
+ episode;
89
+ /** Set while parked in 'unauthorized'. */
90
+ refusal;
91
+ /**
92
+ * token|reason|side of the last refusal we logged. A resume (tab refocus,
93
+ * `online`) that ends in the identical refusal is the same news — it goes
94
+ * to debug, not another console error.
95
+ */
96
+ lastRefusalKey;
97
+ /**
98
+ * Token handed back by `refreshAuthToken`, used while the host's
99
+ * `getAuthToken()` still returns the token it replaced (`staleToken`). Hosts
100
+ * may not have updated their store by the time the refresh resolves.
101
+ */
102
+ freshToken;
103
+ staleToken;
104
+ /** A socket we closed on purpose (setUserId & co.) — its close is not a fault. */
105
+ expectedCloseWs;
106
+ resumeListenersInstalled = false;
107
+ /** Runs only while parked — see startParkedTokenCheck. */
108
+ parkedTokenTimer;
109
+ /** `Date.now()` of the last 'open' report — see STATUS_REPORT_OPEN_INTERVAL_MS. */
110
+ lastOpenReportAt;
58
111
  constructor(cfg) {
59
112
  const defaultWs = ((url, protocols) =>
60
113
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -71,9 +124,30 @@ export class RealtimeClient {
71
124
  websocketFactory: cfg.websocketFactory ?? defaultWs,
72
125
  fetchFn: cfg.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined),
73
126
  getAuthToken: cfg.getAuthToken,
127
+ refreshAuthToken: cfg.refreshAuthToken,
128
+ diagnose: cfg.diagnose !== false,
129
+ docsBaseUrl: cfg.docsBaseUrl ?? REALTIME_DOCS_BASE_URL,
130
+ reportStatus: cfg.reportStatus !== false,
74
131
  logger: cfg.logger ?? createLogger(false),
75
132
  };
76
133
  this.reconnectDelayMs = this.cfg.reconnectBaseMs;
134
+ this.status = { state: 'idle', retrying: false, since: Date.now() };
135
+ }
136
+ /**
137
+ * Current connection status with the reason, whose side a fault is on, and
138
+ * whether the client is still retrying (TBP-643). `getState()` is the same
139
+ * `state` without the explanation.
140
+ */
141
+ getStatus() {
142
+ return { ...this.status };
143
+ }
144
+ /**
145
+ * Register a hook fired on every status change (state, reason, side or
146
+ * retrying). Use it to drive a "live updates off" indicator. Hook errors are
147
+ * swallowed, like every other hook here.
148
+ */
149
+ setOnStatusChange(hook) {
150
+ this.onStatusChangeHook = hook;
77
151
  }
78
152
  /** Attach to a BridgeFlags instance — flag updates auto-apply to its cache. */
79
153
  attach(bridge) {
@@ -213,10 +287,17 @@ export class RealtimeClient {
213
287
  this.reconnectTimer = undefined;
214
288
  }
215
289
  this.reconnectDelayMs = this.cfg.reconnectBaseMs;
290
+ // TBP-643 — an explicit reauthorize is the host saying "try again": it
291
+ // lifts a parked refusal and starts a fresh episode. An auth episode that
292
+ // is still in flight is deliberately NOT reset — the host calls this
293
+ // whenever its token changes, including after the refresh WE asked for,
294
+ // and resetting would re-arm that refresh and loop.
295
+ if (this.state === 'unauthorized')
296
+ this.clearRefusal();
216
297
  if (this.ws) {
217
298
  const oldWs = this.ws;
218
299
  this.ws = undefined;
219
- this.state = 'closed';
300
+ this.setState('closed');
220
301
  try {
221
302
  oldWs.close(1000, 'sdk.reauthorize');
222
303
  }
@@ -224,6 +305,9 @@ export class RealtimeClient {
224
305
  // ignore
225
306
  }
226
307
  }
308
+ else if (this.state === 'unauthorized') {
309
+ this.setState('closed');
310
+ }
227
311
  await this.start();
228
312
  }
229
313
  /**
@@ -237,6 +321,7 @@ export class RealtimeClient {
237
321
  return;
238
322
  this.cfg.appId = appId;
239
323
  if (this.ws) {
324
+ this.expectedCloseWs = this.ws;
240
325
  this.ws.close(1000, 'sdk.setAppId');
241
326
  // onclose → scheduleReconnect → start() picks up updated channelsToSubscribe()
242
327
  }
@@ -251,6 +336,7 @@ export class RealtimeClient {
251
336
  return;
252
337
  this.cfg.workspaceId = workspaceId;
253
338
  if (this.ws) {
339
+ this.expectedCloseWs = this.ws;
254
340
  this.ws.close(1000, 'sdk.setWorkspaceId');
255
341
  }
256
342
  }
@@ -264,6 +350,7 @@ export class RealtimeClient {
264
350
  return;
265
351
  this.cfg.userId = userId;
266
352
  if (this.ws) {
353
+ this.expectedCloseWs = this.ws;
267
354
  this.ws.close(1000, 'sdk.setUserId');
268
355
  // onclose fires → scheduleReconnect → start() picks up updated channelsToSubscribe()
269
356
  }
@@ -272,13 +359,25 @@ export class RealtimeClient {
272
359
  async start() {
273
360
  if (!this.cfg.enabled || this.stopped)
274
361
  return;
275
- if (this.state !== 'idle' && this.state !== 'closed')
362
+ if (this.state === 'unauthorized') {
363
+ // Parked after a refusal (TBP-643). The same token would only be refused
364
+ // again, so a plain start() with it stays parked; a different token is
365
+ // a new session and gets a fresh episode.
366
+ if (this.currentToken() === this.refusal?.token)
367
+ return;
368
+ this.clearRefusal();
369
+ }
370
+ else if (this.state !== 'idle' && this.state !== 'closed') {
276
371
  return;
277
- this.state = 'connecting';
372
+ }
373
+ this.installResumeListeners();
374
+ this.setState('connecting');
278
375
  try {
279
376
  const serverConfig = await this.fetchServerConfig();
280
377
  if (serverConfig.kind === 'noop' || !serverConfig.endpoint) {
281
- this.state = 'closed';
378
+ // Realtime is off for this workspace — nothing to retry or report.
379
+ this.episode = undefined;
380
+ this.setState('closed');
282
381
  return;
283
382
  }
284
383
  const channels = this.channelsToSubscribe();
@@ -290,16 +389,30 @@ export class RealtimeClient {
290
389
  return;
291
390
  }
292
391
  if (serverConfig.kind === 'centrifugo') {
293
- const auth = await this.authorize(channels);
392
+ const userToken = this.currentToken();
393
+ let auth;
394
+ try {
395
+ auth = await this.authorize(channels, userToken);
396
+ }
397
+ catch (err) {
398
+ // A 401/403 from /realtime/authorize is the same refusal AppSync
399
+ // reports as connection_error — retrying on a backoff can't fix it.
400
+ if (err instanceof RealtimeAuthRefusedError) {
401
+ await this.handleAuthRefusal(userToken, channels);
402
+ return;
403
+ }
404
+ throw err;
405
+ }
294
406
  this.openWebSocket(serverConfig.endpoint, auth);
295
407
  return;
296
408
  }
297
409
  // Unknown protocol — close cleanly so consumers don't get stuck in
298
410
  // 'connecting'. New transports must be added explicitly here.
299
- this.state = 'closed';
411
+ this.setState('closed');
300
412
  }
301
- catch {
302
- this.state = 'closed';
413
+ catch (err) {
414
+ this.beginTransient('setup_failed', `could not reach Bridge to set up the connection (${errorText(err)})`, 'warn');
415
+ this.setState('closed');
303
416
  this.scheduleReconnect();
304
417
  }
305
418
  }
@@ -307,6 +420,7 @@ export class RealtimeClient {
307
420
  async stop() {
308
421
  this.stopped = true;
309
422
  this.clearSubscribeAckTimer();
423
+ this.removeResumeListeners();
310
424
  if (this.reconnectTimer) {
311
425
  clearTimeout(this.reconnectTimer);
312
426
  this.reconnectTimer = undefined;
@@ -320,7 +434,9 @@ export class RealtimeClient {
320
434
  }
321
435
  this.ws = undefined;
322
436
  }
323
- this.state = 'closed';
437
+ this.episode = undefined;
438
+ this.clearRefusal();
439
+ this.setState('closed');
324
440
  }
325
441
  /** Read connection state. */
326
442
  getState() {
@@ -356,8 +472,8 @@ export class RealtimeClient {
356
472
  }
357
473
  return (await res.json());
358
474
  }
359
- async authorize(channels) {
360
- const token = this.cfg.getAuthToken?.() ?? this.cfg.apiKey;
475
+ async authorize(channels, userToken) {
476
+ const token = userToken ?? this.cfg.apiKey;
361
477
  const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/authorize`, {
362
478
  method: 'POST',
363
479
  headers: {
@@ -366,6 +482,9 @@ export class RealtimeClient {
366
482
  },
367
483
  body: JSON.stringify({ channels }),
368
484
  });
485
+ if (res.status === 401 || res.status === 403) {
486
+ throw new RealtimeAuthRefusedError(res.status);
487
+ }
369
488
  if (!res.ok) {
370
489
  throw new Error(`realtime authorize failed: ${res.status}`);
371
490
  }
@@ -377,7 +496,6 @@ export class RealtimeClient {
377
496
  ws.onopen = () => {
378
497
  if (this.ws !== ws)
379
498
  return;
380
- this.state = 'open';
381
499
  this.reconnectDelayMs = this.cfg.reconnectBaseMs;
382
500
  // Send connect with the signed token + channels. Centrifugo expects a
383
501
  // command frame like `{ "connect": { "token": "..." }, "id": 1 }` and
@@ -389,12 +507,7 @@ export class RealtimeClient {
389
507
  catch {
390
508
  // ignore
391
509
  }
392
- try {
393
- this.onOpenHook?.();
394
- }
395
- catch {
396
- // hook errors must not break the connection
397
- }
510
+ this.markOpen();
398
511
  };
399
512
  ws.onmessage = (ev) => {
400
513
  if (this.ws !== ws)
@@ -427,7 +540,8 @@ export class RealtimeClient {
427
540
  // is from a stale socket. Don't flap state or fire hooks.
428
541
  if (this.ws !== ws)
429
542
  return;
430
- this.state = 'closed';
543
+ this.noteClose(ws);
544
+ this.setState('closed');
431
545
  try {
432
546
  this.onCloseHook?.();
433
547
  }
@@ -461,8 +575,10 @@ export class RealtimeClient {
461
575
  * `appsync-events.adapter.ts:87` and `appsync-authorizer.handler.ts:59`).
462
576
  *
463
577
  * Anonymous flow: `getAuthToken()` returns undefined → Authorization sent as
464
- * empty string. The Lambda authorizer accepts that only for `app:<appId>`
465
- * channels whose origin matches the app's allowedOrigins (demo path).
578
+ * the marker `REALTIME_ANONYMOUS_TOKEN` (see buildAppSyncAuthHeader for why
579
+ * it can't be empty). The Lambda authorizer treats exactly that value as "no
580
+ * token": CONNECT allowed, `app:<appId>` channels origin-checked against the
581
+ * app's allowedOrigins, everything else denied `no_token`.
466
582
  */
467
583
  openAppSyncWebSocket(endpoint, channels) {
468
584
  const { url, httpHost } = normalizeAppSyncEndpoint(endpoint);
@@ -471,7 +587,10 @@ export class RealtimeClient {
471
587
  // server-side validation uses this to verify the connection — sending
472
588
  // the realtime host instead produces a silent close 1000 right after
473
589
  // the WS upgrade succeeds.
474
- const authHeader = buildAppSyncAuthHeader(this.cfg.getAuthToken?.(), httpHost);
590
+ // Captured once: a refusal must be diagnosed against the token that was
591
+ // actually presented, not whatever the host holds by the time we look.
592
+ const token = this.currentToken();
593
+ const authHeader = buildAppSyncAuthHeader(token, httpHost);
475
594
  const headerProtocol = `header-${base64urlEncode(JSON.stringify(authHeader))}`;
476
595
  const ws = this.cfg.websocketFactory(url, [APPSYNC_WS_PROTOCOL, headerProtocol]);
477
596
  this.ws = ws;
@@ -580,14 +699,8 @@ export class RealtimeClient {
580
699
  this.ackedChannels.add(channel);
581
700
  }
582
701
  if (this.state !== 'open') {
583
- this.state = 'open';
584
702
  this.clearSubscribeAckTimer();
585
- try {
586
- this.onOpenHook?.();
587
- }
588
- catch {
589
- // hook errors must not break the connection.
590
- }
703
+ this.markOpen();
591
704
  }
592
705
  break;
593
706
  }
@@ -609,8 +722,20 @@ export class RealtimeClient {
609
722
  }
610
723
  case 'connection_error':
611
724
  case 'error':
612
- // Connection-level fault — close cleanly so onclose triggers reconnect.
613
- this.cfg.logger.error(`realtime: AppSync ${type} — ${describeAppSyncError(frame)}. Reconnecting.`);
725
+ // TBP-643 — an auth refusal is not a blip. Reconnecting with the same
726
+ // token can only be refused again; before this branch existed the
727
+ // client did exactly that on a backoff loop forever, logging a
728
+ // reason-less line each time. AppSync never relays the authorizer's
729
+ // reason, so detect by errorType/errorCode and work out the "why"
730
+ // ourselves (pre-checks → one refresh → one diagnose).
731
+ if (isAppSyncAuthRefusal(frame)) {
732
+ this.detachSocket(ws, `appsync:${type}`);
733
+ void this.handleAuthRefusal(token, channels);
734
+ break;
735
+ }
736
+ // Anything else is a connection-level fault worth retrying. Logged
737
+ // once per episode (beginTransient), not once per attempt.
738
+ this.beginTransient('server_error', `the realtime server reported ${type}: ${describeAppSyncError(frame)}`, 'error');
614
739
  try {
615
740
  ws.close(1011, `appsync:${type}`);
616
741
  }
@@ -627,7 +752,8 @@ export class RealtimeClient {
627
752
  ws.onclose = () => {
628
753
  if (this.ws !== ws)
629
754
  return;
630
- this.state = 'closed';
755
+ this.noteClose(ws);
756
+ this.setState('closed');
631
757
  this.resetSubscribeTracking();
632
758
  try {
633
759
  this.onCloseHook?.();
@@ -661,7 +787,13 @@ export class RealtimeClient {
661
787
  * polling or warn the user.
662
788
  */
663
789
  markDegraded() {
664
- this.state = 'degraded';
790
+ // The transport did come back; degraded has its own error log above, so a
791
+ // pending "restored" line would be misleading — drop the episode quietly.
792
+ this.episode = undefined;
793
+ const alreadyDegraded = this.state === 'degraded';
794
+ this.setState('degraded');
795
+ if (!alreadyDegraded)
796
+ this.sendStatusReport({ state: 'degraded', reason: 'no_channel_accepted' });
665
797
  try {
666
798
  this.onDegradedHook?.();
667
799
  }
@@ -788,12 +920,517 @@ export class RealtimeClient {
788
920
  return;
789
921
  this.reconnectTimer = setTimeout(() => {
790
922
  this.reconnectTimer = undefined;
791
- this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
792
- void this.start();
923
+ this.fireReconnect();
793
924
  }, this.reconnectDelayMs);
794
925
  if (this.reconnectTimer?.unref)
795
926
  this.reconnectTimer.unref();
796
927
  }
928
+ fireReconnect() {
929
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
930
+ if (this.episode)
931
+ this.episode.attempts++;
932
+ void this.start();
933
+ }
934
+ // ── Status + fault reporting (TBP-643) ────────────────────────────────────
935
+ setState(state) {
936
+ this.state = state;
937
+ this.publishStatus();
938
+ }
939
+ publishStatus() {
940
+ const detail = this.statusDetail();
941
+ const prev = this.status;
942
+ if (prev.state === this.state &&
943
+ prev.reason === detail.reason &&
944
+ prev.side === detail.side &&
945
+ prev.retrying === detail.retrying &&
946
+ prev.ref === detail.ref) {
947
+ return;
948
+ }
949
+ this.status = { state: this.state, ...detail, since: Date.now() };
950
+ if (!this.onStatusChangeHook)
951
+ return;
952
+ try {
953
+ this.onStatusChangeHook({ ...this.status });
954
+ }
955
+ catch {
956
+ // a consumer's indicator must never break the connection.
957
+ }
958
+ }
959
+ statusDetail() {
960
+ if (this.state === 'unauthorized' && this.refusal) {
961
+ const r = this.refusal;
962
+ return { reason: r.reason, side: r.side, retrying: false, docsUrl: r.docsUrl, ref: r.ref };
963
+ }
964
+ if (this.state === 'degraded')
965
+ return { reason: 'no_channel_accepted', retrying: false };
966
+ const ep = this.episode;
967
+ if (ep?.kind === 'transient') {
968
+ return { reason: ep.reason, side: 'network', retrying: true, ref: ep.ref };
969
+ }
970
+ // Auth episode in flight: refreshing or diagnosing — not given up yet.
971
+ if (ep?.kind === 'auth')
972
+ return { reason: ep.reason, retrying: true, ref: ep.ref };
973
+ return { retrying: false };
974
+ }
975
+ /** The token to present — see `freshToken`. */
976
+ currentToken() {
977
+ const host = this.cfg.getAuthToken?.();
978
+ if (this.freshToken !== undefined) {
979
+ if (host === this.staleToken)
980
+ return this.freshToken;
981
+ // The host has caught up (or moved on) — its value wins from here.
982
+ this.freshToken = undefined;
983
+ this.staleToken = undefined;
984
+ }
985
+ return host;
986
+ }
987
+ /** The transport is usable. Closes any episode, logging recovery if we logged the fault. */
988
+ markOpen() {
989
+ const ep = this.episode;
990
+ this.episode = undefined;
991
+ this.lastRefusalKey = undefined;
992
+ this.setState('open');
993
+ const now = Date.now();
994
+ if (this.lastOpenReportAt === undefined || now - this.lastOpenReportAt >= STATUS_REPORT_OPEN_INTERVAL_MS) {
995
+ if (this.sendStatusReport({ state: 'open' }))
996
+ this.lastOpenReportAt = now;
997
+ }
998
+ if (ep?.kind === 'transient' && ep.logLevel) {
999
+ const n = Math.max(ep.attempts, 1);
1000
+ this.cfg.logger[ep.logLevel](`[bridge] Live updates restored after ${n} attempt${n === 1 ? '' : 's'}.`);
1001
+ }
1002
+ try {
1003
+ this.onOpenHook?.();
1004
+ }
1005
+ catch {
1006
+ // hook errors must not break the connection.
1007
+ }
1008
+ }
1009
+ /** Called from onclose of the CURRENT socket: an unplanned close starts a transient episode. */
1010
+ noteClose(ws) {
1011
+ if (this.expectedCloseWs === ws) {
1012
+ this.expectedCloseWs = undefined;
1013
+ return;
1014
+ }
1015
+ this.beginTransient('connection_lost', 'the realtime connection dropped', 'warn');
1016
+ }
1017
+ /**
1018
+ * Open a transient episode if none is running, and log its start ONCE.
1019
+ * Server-reported errors log at `error` (they used to, and they are real
1020
+ * faults); plain drops and fetch failures log at `warn` — sleep/wake and
1021
+ * wifi changes cause them constantly and they fix themselves, so they must
1022
+ * not paint every end-user console red.
1023
+ */
1024
+ beginTransient(reason, detail, level) {
1025
+ if (this.episode)
1026
+ return;
1027
+ const ref = newRef();
1028
+ this.episode = {
1029
+ kind: 'transient',
1030
+ ref,
1031
+ attempts: 0,
1032
+ reason,
1033
+ logLevel: level,
1034
+ refreshed: false,
1035
+ diagnosed: false,
1036
+ };
1037
+ this.cfg.logger[level](`[bridge] Live updates interrupted — ${detail}. Retrying in the background; plan, entitlement and feature-flag changes resume when it reconnects. ref ${ref}`);
1038
+ }
1039
+ /** Drop a socket without letting its onclose schedule a reconnect. */
1040
+ detachSocket(ws, reason) {
1041
+ if (this.ws === ws)
1042
+ this.ws = undefined;
1043
+ this.resetSubscribeTracking();
1044
+ try {
1045
+ ws.close(1011, reason);
1046
+ }
1047
+ catch {
1048
+ // ignore
1049
+ }
1050
+ try {
1051
+ this.onCloseHook?.();
1052
+ }
1053
+ catch {
1054
+ // hook errors must not break refusal handling.
1055
+ }
1056
+ }
1057
+ /**
1058
+ * Bridge refused `token` (TBP-643). Policy, in order:
1059
+ * a. client-side pre-checks on the token — cheap, and they name the fix;
1060
+ * b. one host refresh + an immediate reconnect, if the host gave us a hook;
1061
+ * c. if still refused and the pre-checks found nothing, ask Bridge once;
1062
+ * d. park in 'unauthorized', stop reconnecting, log ONE message.
1063
+ * Resumes on reauthorize(), a changed token on start(), `online`, or the
1064
+ * tab becoming visible.
1065
+ */
1066
+ async handleAuthRefusal(token, channels) {
1067
+ if (this.stopped)
1068
+ return;
1069
+ if (this.reconnectTimer) {
1070
+ clearTimeout(this.reconnectTimer);
1071
+ this.reconnectTimer = undefined;
1072
+ }
1073
+ let ep = this.episode;
1074
+ if (!ep || ep.kind !== 'auth') {
1075
+ ep = {
1076
+ kind: 'auth',
1077
+ ref: newRef(),
1078
+ attempts: 0,
1079
+ reason: 'refused',
1080
+ refreshed: false,
1081
+ diagnosed: false,
1082
+ };
1083
+ this.episode = ep;
1084
+ }
1085
+ this.setState('connecting');
1086
+ if (this.cfg.refreshAuthToken && !ep.refreshed) {
1087
+ ep.refreshed = true;
1088
+ let fresh;
1089
+ try {
1090
+ fresh = await this.cfg.refreshAuthToken();
1091
+ }
1092
+ catch {
1093
+ fresh = undefined;
1094
+ }
1095
+ if (this.episode !== ep || this.stopped)
1096
+ return;
1097
+ // Same token back = nothing to retry with; fall through to diagnosis.
1098
+ if (fresh && fresh !== token) {
1099
+ this.freshToken = fresh;
1100
+ this.staleToken = this.cfg.getAuthToken?.();
1101
+ this.setState('closed');
1102
+ await this.start();
1103
+ return;
1104
+ }
1105
+ }
1106
+ let verdict = this.precheckToken(token, channels);
1107
+ if (!verdict && this.cfg.diagnose && !ep.diagnosed) {
1108
+ ep.diagnosed = true;
1109
+ verdict = await this.diagnoseRefusal(token, channels, ep.ref);
1110
+ if (this.episode !== ep || this.stopped)
1111
+ return;
1112
+ }
1113
+ // Nothing explained it. With a token: Bridge-issued, unexpired, right
1114
+ // environment and app, yet refused — Bridge's problem, not the app's.
1115
+ // Without one: the server would not take an anonymous connect (e.g. an
1116
+ // authorizer that predates the anonymous marker). That is not a fault in
1117
+ // the app either, but "a problem on Bridge's side" would send developers
1118
+ // chasing an outage — it gets its own reason and wording, and the parked
1119
+ // client resumes as soon as a user signs in.
1120
+ const fallback = token
1121
+ ? { reason: 'refused', side: 'bridge' }
1122
+ : { reason: 'anonymous_refused', side: 'bridge' };
1123
+ this.enterUnauthorized(token, verdict ?? fallback, ep);
1124
+ }
1125
+ /** Explain a refusal from the token alone. Decodes without verifying — this is diagnosis, not auth. */
1126
+ precheckToken(token, channels) {
1127
+ if (!token) {
1128
+ // Anonymous is a legitimate state for app-only channels (`app:<id>`).
1129
+ // It is only a fault when a channel needs a user — workspace, user,
1130
+ // integration: anything that is not `app:`.
1131
+ return channels.some((c) => !c.startsWith('app:'))
1132
+ ? { reason: 'no_token', side: 'app' }
1133
+ : undefined;
1134
+ }
1135
+ const claims = decodeJwtPayload(token);
1136
+ if (!claims)
1137
+ return { reason: 'malformed', side: 'app' };
1138
+ const expectedIssuer = `${this.cfg.apiBaseUrl}/auth`;
1139
+ if (typeof claims.iss === 'string' && !claims.iss.startsWith(expectedIssuer)) {
1140
+ return { reason: 'wrong_environment', side: 'config', tokenIssuer: claims.iss };
1141
+ }
1142
+ if (this.cfg.appId && typeof claims.aid === 'string' && claims.aid !== this.cfg.appId) {
1143
+ return { reason: 'wrong_app', side: 'config', tokenAppId: claims.aid };
1144
+ }
1145
+ if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
1146
+ return { reason: 'expired', side: 'app' };
1147
+ }
1148
+ return undefined;
1149
+ }
1150
+ /**
1151
+ * Ask Bridge why it refused (contract: `POST /realtime/diagnose` →
1152
+ * `{ ok, reason, side }`, sharing the authorizer's classifier). The endpoint
1153
+ * may not exist yet — any failure returns undefined and the caller falls
1154
+ * back to 'refused'.
1155
+ */
1156
+ async diagnoseRefusal(token, channels, ref) {
1157
+ const headers = {
1158
+ 'Content-Type': 'application/json',
1159
+ 'x-bridge-realtime-ref': ref,
1160
+ };
1161
+ if (token)
1162
+ headers.Authorization = `Bearer ${token}`;
1163
+ if (this.cfg.appId)
1164
+ headers['x-app-id'] = this.cfg.appId;
1165
+ let timer;
1166
+ try {
1167
+ const timeout = new Promise((_, reject) => {
1168
+ timer = setTimeout(() => reject(new Error('diagnose timed out')), DIAGNOSE_TIMEOUT_MS);
1169
+ });
1170
+ const res = await Promise.race([
1171
+ this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/diagnose`, {
1172
+ method: 'POST',
1173
+ headers,
1174
+ body: JSON.stringify({ channels }),
1175
+ }),
1176
+ timeout,
1177
+ ]);
1178
+ if (!res.ok)
1179
+ return undefined;
1180
+ const body = (await res.json());
1181
+ // Bridge sees nothing wrong — no verdict; the caller's fallback applies.
1182
+ if (body?.ok === true)
1183
+ return undefined;
1184
+ const reason = typeof body?.reason === 'string' && /^[a-z0-9_]+$/.test(body.reason) ? body.reason : 'refused';
1185
+ const side = body?.side === 'app' || body?.side === 'config' || body?.side === 'bridge' ? body.side : 'bridge';
1186
+ return { reason, side };
1187
+ }
1188
+ catch {
1189
+ return undefined;
1190
+ }
1191
+ finally {
1192
+ if (timer)
1193
+ clearTimeout(timer);
1194
+ }
1195
+ }
1196
+ enterUnauthorized(token, verdict, ep) {
1197
+ const docsUrl = `${this.cfg.docsBaseUrl}#${verdict.reason}`;
1198
+ this.refusal = { ...verdict, token, ref: ep.ref, docsUrl };
1199
+ this.episode = undefined;
1200
+ this.setState('unauthorized');
1201
+ this.startParkedTokenCheck();
1202
+ const message = this.formatRefusal(this.refusal);
1203
+ const key = `${token ?? ''}|${verdict.reason}|${verdict.side}`;
1204
+ if (key === this.lastRefusalKey) {
1205
+ this.cfg.logger.debug(message);
1206
+ return;
1207
+ }
1208
+ this.lastRefusalKey = key;
1209
+ this.cfg.logger.error(message);
1210
+ // Reported under the same dedupe as the log: a resume (tab refocus,
1211
+ // `online`) that ends in the identical refusal is not new health news.
1212
+ this.sendStatusReport({ state: 'unauthorized', reason: verdict.reason, side: verdict.side, ref: ep.ref });
1213
+ }
1214
+ /**
1215
+ * TBP-645 — fire-and-forget health report. Returns whether a report was
1216
+ * dispatched (false when opted out, or there is no appId to attribute it
1217
+ * to). Never awaited by the caller and never throws: this runs on the
1218
+ * connect path, and telemetry must not be able to hurt the connection. The
1219
+ * endpoint may not exist yet — every response, 404 included, is ignored.
1220
+ */
1221
+ sendStatusReport(report) {
1222
+ const appId = this.cfg.appId;
1223
+ if (!this.cfg.reportStatus || !appId || typeof this.cfg.fetchFn !== 'function')
1224
+ return false;
1225
+ try {
1226
+ void this.postStatusReport(appId, report);
1227
+ }
1228
+ catch {
1229
+ // never
1230
+ }
1231
+ return true;
1232
+ }
1233
+ async postStatusReport(appId, report) {
1234
+ let timer;
1235
+ try {
1236
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1237
+ const AC = globalThis.AbortController;
1238
+ const controller = typeof AC === 'function' ? new AC() : undefined;
1239
+ const timeout = new Promise((resolve) => {
1240
+ timer = setTimeout(() => {
1241
+ try {
1242
+ controller?.abort();
1243
+ }
1244
+ catch {
1245
+ // ignore
1246
+ }
1247
+ resolve();
1248
+ }, STATUS_REPORT_TIMEOUT_MS);
1249
+ if (timer?.unref)
1250
+ timer.unref();
1251
+ });
1252
+ const body = { state: report.state };
1253
+ if (report.reason)
1254
+ body.reason = report.reason;
1255
+ if (report.side)
1256
+ body.side = report.side;
1257
+ if (report.ref)
1258
+ body.ref = report.ref;
1259
+ // Deferred so a synchronously-throwing fetch lands in the handler too.
1260
+ const request = Promise.resolve().then(() => this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/account/auth/realtime-status`, {
1261
+ method: 'POST',
1262
+ headers: { 'Content-Type': 'application/json', 'x-app-id': appId },
1263
+ body: JSON.stringify(body),
1264
+ signal: controller?.signal,
1265
+ }));
1266
+ await Promise.race([request.then(noop, noop), timeout]);
1267
+ }
1268
+ catch {
1269
+ // swallowed — see sendStatusReport
1270
+ }
1271
+ finally {
1272
+ if (timer)
1273
+ clearTimeout(timer);
1274
+ }
1275
+ }
1276
+ clearRefusal() {
1277
+ this.refusal = undefined;
1278
+ if (this.parkedTokenTimer) {
1279
+ clearInterval(this.parkedTokenTimer);
1280
+ this.parkedTokenTimer = undefined;
1281
+ }
1282
+ }
1283
+ /**
1284
+ * A parked client must resume when the host's token changes — most
1285
+ * importantly when a signed-out session signs in (undefined → token).
1286
+ * Framework SDKs only call reauthorize() when one token REPLACES another
1287
+ * (TBP-644 fixes that), so auth-core can't rely on being told.
1288
+ *
1289
+ * Chosen over a `notifyAuthTokenChanged()` API because it needs no host
1290
+ * change — the missing host call is the bug. It is cheap and bounded: it
1291
+ * runs only while parked, calls the synchronous `getAuthToken()` getter (no
1292
+ * network), and resumes only on a token DIFFERENT from the refused one, so
1293
+ * an unchanged session can never turn it into a reconnect loop.
1294
+ */
1295
+ startParkedTokenCheck() {
1296
+ if (this.parkedTokenTimer || !this.cfg.getAuthToken)
1297
+ return;
1298
+ this.parkedTokenTimer = setInterval(() => {
1299
+ if (this.state !== 'unauthorized' || this.stopped) {
1300
+ this.clearRefusal();
1301
+ return;
1302
+ }
1303
+ if (this.currentToken() !== this.refusal?.token)
1304
+ void this.start();
1305
+ }, PARKED_TOKEN_CHECK_MS);
1306
+ if (this.parkedTokenTimer?.unref)
1307
+ this.parkedTokenTimer.unref();
1308
+ }
1309
+ /**
1310
+ * The one message a developer gets per refused episode. Product terms, whose
1311
+ * side it is, what still works, one next step, a docs link and a ref.
1312
+ */
1313
+ formatRefusal(r) {
1314
+ const stopped = ' Stopped: plan & entitlement changes, feature-flag flips, the plan-changed token refresh. They appear only after a reload.';
1315
+ const stillFine = ' Still fine: every API call your app makes.';
1316
+ const footer = ` ${r.docsUrl} · ref ${r.ref}`;
1317
+ if (r.reason === 'anonymous_refused') {
1318
+ return [
1319
+ `[bridge] Live updates are unavailable before sign-in — Bridge did not accept this signed-out session's realtime connection (${r.reason}).`,
1320
+ ' They start automatically once a user signs in. Until then, feature-flag flips appear only after a reload.',
1321
+ stillFine,
1322
+ ' Nothing to change in your code.',
1323
+ footer,
1324
+ ].join('\n');
1325
+ }
1326
+ if (r.side === 'bridge') {
1327
+ return [
1328
+ "[bridge] Live updates are OFF — this is a problem on Bridge's side, not in your app.",
1329
+ ` Bridge refused this session's realtime connection (${r.reason}) although the session token checks out.`,
1330
+ stopped,
1331
+ stillFine,
1332
+ ` Nothing to change in your code — include ref ${r.ref} if you contact support.`,
1333
+ footer,
1334
+ ].join('\n');
1335
+ }
1336
+ if (r.side === 'config') {
1337
+ const apiHost = hostOf(this.cfg.apiBaseUrl);
1338
+ let mismatch;
1339
+ let fix;
1340
+ if (r.reason === 'wrong_environment' && r.tokenIssuer) {
1341
+ const tokenHost = hostOf(r.tokenIssuer);
1342
+ mismatch = ` This app's Bridge API host is ${apiHost}, but the signed-in session's token was issued by ${tokenHost}.`;
1343
+ fix = ` Fix: point the Bridge API base URL setting (apiBaseUrl) at ${tokenHost}, or sign users in against ${apiHost} — both must be the same environment.`;
1344
+ }
1345
+ else if (r.reason === 'wrong_app' && r.tokenAppId) {
1346
+ mismatch = ` This app is configured with app id ${this.cfg.appId}, but the signed-in session's token belongs to app ${r.tokenAppId}.`;
1347
+ fix = ` Fix: set the appId setting to ${r.tokenAppId}, or sign users in through app ${this.cfg.appId} — both must name the same app.`;
1348
+ }
1349
+ else {
1350
+ mismatch = ` This app's Bridge settings (API host ${apiHost}${this.cfg.appId ? `, app id ${this.cfg.appId}` : ''}) don't match the signed-in session.`;
1351
+ fix = ` Fix: correct the Bridge setting the docs entry below names for '${r.reason}'.`;
1352
+ }
1353
+ return [
1354
+ `[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}): your Bridge settings don't match the session.`,
1355
+ mismatch,
1356
+ stopped,
1357
+ fix,
1358
+ footer,
1359
+ ].join('\n');
1360
+ }
1361
+ return [
1362
+ `[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}).`,
1363
+ stopped,
1364
+ stillFine,
1365
+ ` Fix: ${appFix(r.reason)}.`,
1366
+ footer,
1367
+ ].join('\n');
1368
+ }
1369
+ // ── Resume triggers (browser only; guarded so Node/SSR never touches them) ──
1370
+ onOnline = () => this.nudge();
1371
+ onVisibilityChange = () => {
1372
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1373
+ if (globalThis.document?.visibilityState === 'visible')
1374
+ this.nudge();
1375
+ };
1376
+ installResumeListeners() {
1377
+ if (this.resumeListenersInstalled)
1378
+ return;
1379
+ this.resumeListenersInstalled = true;
1380
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1381
+ const g = globalThis;
1382
+ if (typeof g.addEventListener === 'function')
1383
+ g.addEventListener('online', this.onOnline);
1384
+ if (typeof g.document?.addEventListener === 'function') {
1385
+ g.document.addEventListener('visibilitychange', this.onVisibilityChange);
1386
+ }
1387
+ }
1388
+ removeResumeListeners() {
1389
+ if (!this.resumeListenersInstalled)
1390
+ return;
1391
+ this.resumeListenersInstalled = false;
1392
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1393
+ const g = globalThis;
1394
+ if (typeof g.removeEventListener === 'function')
1395
+ g.removeEventListener('online', this.onOnline);
1396
+ if (typeof g.document?.removeEventListener === 'function') {
1397
+ g.document.removeEventListener('visibilitychange', this.onVisibilityChange);
1398
+ }
1399
+ }
1400
+ /**
1401
+ * The network came back or the user returned to the tab: the conditions
1402
+ * behind a refusal may have changed (host refreshed the session, clock
1403
+ * caught up), so a parked client gets one new episode; a client waiting out
1404
+ * a backoff tries now instead.
1405
+ */
1406
+ nudge() {
1407
+ if (!this.cfg.enabled || this.stopped)
1408
+ return;
1409
+ if (this.state === 'unauthorized') {
1410
+ this.clearRefusal();
1411
+ this.setState('closed');
1412
+ void this.start();
1413
+ return;
1414
+ }
1415
+ if (this.reconnectTimer) {
1416
+ clearTimeout(this.reconnectTimer);
1417
+ this.reconnectTimer = undefined;
1418
+ this.fireReconnect();
1419
+ }
1420
+ }
1421
+ }
1422
+ /** Reason-specific next step for app-side refusals. */
1423
+ function appFix(reason) {
1424
+ switch (reason) {
1425
+ case 'no_token':
1426
+ return "start live updates after sign-in, or pass getAuthToken so the client can read the session's access token";
1427
+ case 'expired':
1428
+ return 'the access token expired and was not refreshed — pass refreshAuthToken to the realtime client (the framework SDKs do this for you), or refresh the session and call reauthorize()';
1429
+ case 'malformed':
1430
+ return 'getAuthToken must return the Bridge access token (a JWT) — not an ID token, an API key or another string';
1431
+ default:
1432
+ return `see the docs entry below for what '${reason}' means for this session`;
1433
+ }
797
1434
  }
798
1435
  function parseMessage(raw) {
799
1436
  if (typeof raw !== 'string')
@@ -825,14 +1462,18 @@ function parseMessage(raw) {
825
1462
  * months. Anything is better than nothing here, so fall back to the raw frame.
826
1463
  */
827
1464
  function describeAppSyncError(frame) {
828
- const { errors, message } = frame;
829
- if (Array.isArray(errors) && errors.length > 0) {
1465
+ const { message } = frame;
1466
+ const errors = appSyncErrors(frame);
1467
+ if (errors.length > 0) {
830
1468
  const parts = errors
831
1469
  .map((e) => {
832
1470
  if (typeof e === 'string')
833
1471
  return e;
834
1472
  const m = e?.message;
835
- return typeof m === 'string' ? m : undefined;
1473
+ if (typeof m === 'string')
1474
+ return m;
1475
+ const t = e?.errorType;
1476
+ return typeof t === 'string' ? t : undefined;
836
1477
  })
837
1478
  .filter((m) => !!m);
838
1479
  if (parts.length > 0)
@@ -847,6 +1488,73 @@ function describeAppSyncError(frame) {
847
1488
  return 'no error detail supplied by the server';
848
1489
  }
849
1490
  }
1491
+ /** AppSync puts `errors` at the top level or under `payload` — accept both. */
1492
+ function appSyncErrors(frame) {
1493
+ if (Array.isArray(frame.errors))
1494
+ return frame.errors;
1495
+ const nested = frame.payload?.errors;
1496
+ return Array.isArray(nested) ? nested : [];
1497
+ }
1498
+ /**
1499
+ * TBP-643 — is this error frame an auth refusal? AppSync Events does NOT relay
1500
+ * the authorizer's reason and often sends no message text at all, so match on
1501
+ * errorType / errorCode only, never on wording.
1502
+ */
1503
+ function isAppSyncAuthRefusal(frame) {
1504
+ return appSyncErrors(frame).some((e) => {
1505
+ if (!e || typeof e !== 'object')
1506
+ return false;
1507
+ const { errorType, errorCode } = e;
1508
+ if (typeof errorType === 'string' && /^unauthori[sz]ed/i.test(errorType))
1509
+ return true;
1510
+ const code = typeof errorCode === 'string' ? Number(errorCode) : errorCode;
1511
+ return code === 401 || code === 403;
1512
+ });
1513
+ }
1514
+ /** Decode a JWT payload WITHOUT verifying it. undefined = not a JWT. */
1515
+ function decodeJwtPayload(token) {
1516
+ const parts = token.split('.');
1517
+ if (parts.length !== 3 || !parts[1])
1518
+ return undefined;
1519
+ try {
1520
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
1521
+ const padded = b64 + '==='.slice((b64.length + 3) % 4);
1522
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1523
+ const g = globalThis;
1524
+ const json = typeof g.atob === 'function'
1525
+ ? decodeURIComponent(escape(g.atob(padded)))
1526
+ : g.Buffer.from(padded, 'base64').toString('utf-8');
1527
+ const claims = JSON.parse(json);
1528
+ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : undefined;
1529
+ }
1530
+ catch {
1531
+ return undefined;
1532
+ }
1533
+ }
1534
+ function hostOf(url) {
1535
+ try {
1536
+ return new URL(url).host;
1537
+ }
1538
+ catch {
1539
+ return url;
1540
+ }
1541
+ }
1542
+ function noop() {
1543
+ // intentionally empty
1544
+ }
1545
+ function errorText(err) {
1546
+ return err instanceof Error ? err.message : String(err);
1547
+ }
1548
+ /** Short per-episode correlation id — quoted in logs, sent to Bridge on diagnose. */
1549
+ function newRef() {
1550
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1551
+ const g = globalThis;
1552
+ if (typeof g.crypto?.getRandomValues === 'function') {
1553
+ const bytes = g.crypto.getRandomValues(new Uint8Array(4));
1554
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
1555
+ }
1556
+ return Math.random().toString(16).slice(2, 10).padEnd(8, '0');
1557
+ }
850
1558
  /** Subprotocol identifier for AppSync Events realtime channels. */
851
1559
  const APPSYNC_WS_PROTOCOL = 'aws-appsync-event-ws';
852
1560
  /**
@@ -888,12 +1596,20 @@ function normalizeAppSyncEndpoint(endpoint) {
888
1596
  }
889
1597
  /**
890
1598
  * Build the AppSync Events auth header carried in the `header-…` subprotocol
891
- * token. Anonymous sessions send Authorization: '' — the Lambda authorizer
892
- * accepts that only for `app:<appId>` channels with a passing origin check.
1599
+ * token — and in every subscribe frame's `authorization`. This is the ONLY
1600
+ * place the anonymous value is decided.
1601
+ *
1602
+ * Anonymous sessions send `Authorization: REALTIME_ANONYMOUS_TOKEN`, never ''.
1603
+ * TBP-643 — AppSync rejects an empty Authorization itself, BEFORE the Lambda
1604
+ * authorizer runs (`connection_error` / UnauthorizedException 401, no
1605
+ * message), so with '' the authorizer's anonymous-CONNECT branch was
1606
+ * unreachable and no signed-out session could ever connect. The authorizer
1607
+ * treats exactly this marker as "no token" (app channels origin-checked,
1608
+ * everything else denied `no_token`).
893
1609
  */
894
1610
  function buildAppSyncAuthHeader(token, host) {
895
1611
  return {
896
- Authorization: token ? `Bearer ${token}` : '',
1612
+ Authorization: token ? `Bearer ${token}` : REALTIME_ANONYMOUS_TOKEN,
897
1613
  host,
898
1614
  };
899
1615
  }