@nebulr-group/bridge-auth-core 0.6.1 → 0.7.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/dist/bridge-auth.d.ts +13 -0
- package/dist/bridge-auth.d.ts.map +1 -1
- package/dist/bridge-auth.js +30 -20
- package/dist/bridge-auth.js.map +1 -1
- package/dist/errors.d.ts +35 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +53 -2
- package/dist/errors.js.map +1 -1
- package/dist/flags/index.d.ts +2 -2
- package/dist/flags/index.d.ts.map +1 -1
- package/dist/flags/index.js +1 -1
- package/dist/flags/index.js.map +1 -1
- package/dist/flags/realtime.d.ts +205 -3
- package/dist/flags/realtime.d.ts.map +1 -1
- package/dist/flags/realtime.js +847 -41
- package/dist/flags/realtime.js.map +1 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +32 -1
- package/dist/http.js.map +1 -1
- package/dist/i18n/messages.d.ts +2 -0
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/messages.js +12 -0
- package/dist/i18n/messages.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/management/branding.service.d.ts +8 -1
- package/dist/management/branding.service.d.ts.map +1 -1
- package/dist/management/branding.service.js +8 -1
- package/dist/management/branding.service.js.map +1 -1
- package/dist/management/workflows.d.ts +38 -4
- package/dist/management/workflows.d.ts.map +1 -1
- package/dist/management/workflows.js +122 -36
- package/dist/management/workflows.js.map +1 -1
- package/dist/management-types.d.ts +95 -15
- package/dist/management-types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/flags/realtime.js
CHANGED
|
@@ -19,8 +19,35 @@
|
|
|
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
|
+
import { currentOrigin, originNotAllowedHint } from '../errors.js';
|
|
23
|
+
// TBP-643 — single place to repoint the docs the terminal messages link to.
|
|
24
|
+
// Links render as `<base>#<reason>`, so every reason code we emit is an anchor
|
|
25
|
+
// on that page — keep codes lowercase_with_underscores.
|
|
26
|
+
export const REALTIME_DOCS_BASE_URL = 'https://thebridge.dev/docs/live-updates/troubleshooting/';
|
|
27
|
+
/**
|
|
28
|
+
* The AppSync `Authorization` value a signed-out session presents. Must be
|
|
29
|
+
* non-empty (AppSync rejects '' before the authorizer runs) and must match
|
|
30
|
+
* what the bridge-api authorizer recognises as "no token" byte for byte.
|
|
31
|
+
*/
|
|
32
|
+
export const REALTIME_ANONYMOUS_TOKEN = 'anonymous';
|
|
22
33
|
/** How long to wait for a subscribe ack before declaring the connection deaf. */
|
|
23
34
|
const SUBSCRIBE_ACK_TIMEOUT_MS = 10_000;
|
|
35
|
+
/** Cap on the diagnose round-trip — a hung call must not leave us 'connecting' forever. */
|
|
36
|
+
const DIAGNOSE_TIMEOUT_MS = 5_000;
|
|
37
|
+
/** While parked in 'unauthorized', how often to look for a new token. */
|
|
38
|
+
const PARKED_TOKEN_CHECK_MS = 5_000;
|
|
39
|
+
/** Healthy connections are reported at most this often (TBP-645). */
|
|
40
|
+
const STATUS_REPORT_OPEN_INTERVAL_MS = 10 * 60_000;
|
|
41
|
+
/** Cap on a status report — it is telemetry, it must not linger. */
|
|
42
|
+
const STATUS_REPORT_TIMEOUT_MS = 3_000;
|
|
43
|
+
/** Centrifugo `/realtime/authorize` answered 401/403 — a refusal, not a blip. */
|
|
44
|
+
class RealtimeAuthRefusedError extends Error {
|
|
45
|
+
status;
|
|
46
|
+
constructor(status) {
|
|
47
|
+
super(`realtime authorize refused: ${status}`);
|
|
48
|
+
this.status = status;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
24
51
|
export class RealtimeClient {
|
|
25
52
|
cfg;
|
|
26
53
|
ws;
|
|
@@ -55,6 +82,44 @@ export class RealtimeClient {
|
|
|
55
82
|
ackedChannels = new Set();
|
|
56
83
|
failedChannels = new Map();
|
|
57
84
|
subscribeAckTimer;
|
|
85
|
+
// ── Fault reporting (TBP-643) ─────────────────────────────────────────────
|
|
86
|
+
status;
|
|
87
|
+
onStatusChangeHook;
|
|
88
|
+
/** The current run of trouble, if any — see FaultEpisode. */
|
|
89
|
+
episode;
|
|
90
|
+
/** Set while parked in 'unauthorized'. */
|
|
91
|
+
refusal;
|
|
92
|
+
/**
|
|
93
|
+
* token|reason|side of the last refusal we logged. A resume (tab refocus,
|
|
94
|
+
* `online`) that ends in the identical refusal is the same news — it goes
|
|
95
|
+
* to debug, not another console error.
|
|
96
|
+
*/
|
|
97
|
+
lastRefusalKey;
|
|
98
|
+
/**
|
|
99
|
+
* TBP-669 — Bridge's verdict on a channel refused at SUBSCRIBE on the
|
|
100
|
+
* current connection. AppSync accepts the CONNECT and refuses the channel
|
|
101
|
+
* with a bare "not authorized"; the reason (e.g. the page's origin is not
|
|
102
|
+
* in the app's allowed origins) only comes from `/realtime/diagnose`.
|
|
103
|
+
*/
|
|
104
|
+
channelRefusal;
|
|
105
|
+
/** Channels already sent to diagnose on the current connection. */
|
|
106
|
+
diagnosedChannels = new Set();
|
|
107
|
+
/** channel|reason|side of the last channel refusal logged — same dedupe as lastRefusalKey. */
|
|
108
|
+
lastChannelRefusalKey;
|
|
109
|
+
/**
|
|
110
|
+
* Token handed back by `refreshAuthToken`, used while the host's
|
|
111
|
+
* `getAuthToken()` still returns the token it replaced (`staleToken`). Hosts
|
|
112
|
+
* may not have updated their store by the time the refresh resolves.
|
|
113
|
+
*/
|
|
114
|
+
freshToken;
|
|
115
|
+
staleToken;
|
|
116
|
+
/** A socket we closed on purpose (setUserId & co.) — its close is not a fault. */
|
|
117
|
+
expectedCloseWs;
|
|
118
|
+
resumeListenersInstalled = false;
|
|
119
|
+
/** Runs only while parked — see startParkedTokenCheck. */
|
|
120
|
+
parkedTokenTimer;
|
|
121
|
+
/** `Date.now()` of the last 'open' report — see STATUS_REPORT_OPEN_INTERVAL_MS. */
|
|
122
|
+
lastOpenReportAt;
|
|
58
123
|
constructor(cfg) {
|
|
59
124
|
const defaultWs = ((url, protocols) =>
|
|
60
125
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -71,9 +136,30 @@ export class RealtimeClient {
|
|
|
71
136
|
websocketFactory: cfg.websocketFactory ?? defaultWs,
|
|
72
137
|
fetchFn: cfg.fetchFn ?? (typeof fetch !== 'undefined' ? fetch : undefined),
|
|
73
138
|
getAuthToken: cfg.getAuthToken,
|
|
139
|
+
refreshAuthToken: cfg.refreshAuthToken,
|
|
140
|
+
diagnose: cfg.diagnose !== false,
|
|
141
|
+
docsBaseUrl: cfg.docsBaseUrl ?? REALTIME_DOCS_BASE_URL,
|
|
142
|
+
reportStatus: cfg.reportStatus !== false,
|
|
74
143
|
logger: cfg.logger ?? createLogger(false),
|
|
75
144
|
};
|
|
76
145
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
146
|
+
this.status = { state: 'idle', retrying: false, since: Date.now() };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Current connection status with the reason, whose side a fault is on, and
|
|
150
|
+
* whether the client is still retrying (TBP-643). `getState()` is the same
|
|
151
|
+
* `state` without the explanation.
|
|
152
|
+
*/
|
|
153
|
+
getStatus() {
|
|
154
|
+
return { ...this.status };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Register a hook fired on every status change (state, reason, side or
|
|
158
|
+
* retrying). Use it to drive a "live updates off" indicator. Hook errors are
|
|
159
|
+
* swallowed, like every other hook here.
|
|
160
|
+
*/
|
|
161
|
+
setOnStatusChange(hook) {
|
|
162
|
+
this.onStatusChangeHook = hook;
|
|
77
163
|
}
|
|
78
164
|
/** Attach to a BridgeFlags instance — flag updates auto-apply to its cache. */
|
|
79
165
|
attach(bridge) {
|
|
@@ -213,10 +299,17 @@ export class RealtimeClient {
|
|
|
213
299
|
this.reconnectTimer = undefined;
|
|
214
300
|
}
|
|
215
301
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
302
|
+
// TBP-643 — an explicit reauthorize is the host saying "try again": it
|
|
303
|
+
// lifts a parked refusal and starts a fresh episode. An auth episode that
|
|
304
|
+
// is still in flight is deliberately NOT reset — the host calls this
|
|
305
|
+
// whenever its token changes, including after the refresh WE asked for,
|
|
306
|
+
// and resetting would re-arm that refresh and loop.
|
|
307
|
+
if (this.state === 'unauthorized')
|
|
308
|
+
this.clearRefusal();
|
|
216
309
|
if (this.ws) {
|
|
217
310
|
const oldWs = this.ws;
|
|
218
311
|
this.ws = undefined;
|
|
219
|
-
this.
|
|
312
|
+
this.setState('closed');
|
|
220
313
|
try {
|
|
221
314
|
oldWs.close(1000, 'sdk.reauthorize');
|
|
222
315
|
}
|
|
@@ -224,6 +317,9 @@ export class RealtimeClient {
|
|
|
224
317
|
// ignore
|
|
225
318
|
}
|
|
226
319
|
}
|
|
320
|
+
else if (this.state === 'unauthorized') {
|
|
321
|
+
this.setState('closed');
|
|
322
|
+
}
|
|
227
323
|
await this.start();
|
|
228
324
|
}
|
|
229
325
|
/**
|
|
@@ -237,6 +333,7 @@ export class RealtimeClient {
|
|
|
237
333
|
return;
|
|
238
334
|
this.cfg.appId = appId;
|
|
239
335
|
if (this.ws) {
|
|
336
|
+
this.expectedCloseWs = this.ws;
|
|
240
337
|
this.ws.close(1000, 'sdk.setAppId');
|
|
241
338
|
// onclose → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
242
339
|
}
|
|
@@ -251,6 +348,7 @@ export class RealtimeClient {
|
|
|
251
348
|
return;
|
|
252
349
|
this.cfg.workspaceId = workspaceId;
|
|
253
350
|
if (this.ws) {
|
|
351
|
+
this.expectedCloseWs = this.ws;
|
|
254
352
|
this.ws.close(1000, 'sdk.setWorkspaceId');
|
|
255
353
|
}
|
|
256
354
|
}
|
|
@@ -264,6 +362,7 @@ export class RealtimeClient {
|
|
|
264
362
|
return;
|
|
265
363
|
this.cfg.userId = userId;
|
|
266
364
|
if (this.ws) {
|
|
365
|
+
this.expectedCloseWs = this.ws;
|
|
267
366
|
this.ws.close(1000, 'sdk.setUserId');
|
|
268
367
|
// onclose fires → scheduleReconnect → start() picks up updated channelsToSubscribe()
|
|
269
368
|
}
|
|
@@ -272,13 +371,25 @@ export class RealtimeClient {
|
|
|
272
371
|
async start() {
|
|
273
372
|
if (!this.cfg.enabled || this.stopped)
|
|
274
373
|
return;
|
|
275
|
-
if (this.state
|
|
374
|
+
if (this.state === 'unauthorized') {
|
|
375
|
+
// Parked after a refusal (TBP-643). The same token would only be refused
|
|
376
|
+
// again, so a plain start() with it stays parked; a different token is
|
|
377
|
+
// a new session and gets a fresh episode.
|
|
378
|
+
if (this.currentToken() === this.refusal?.token)
|
|
379
|
+
return;
|
|
380
|
+
this.clearRefusal();
|
|
381
|
+
}
|
|
382
|
+
else if (this.state !== 'idle' && this.state !== 'closed') {
|
|
276
383
|
return;
|
|
277
|
-
|
|
384
|
+
}
|
|
385
|
+
this.installResumeListeners();
|
|
386
|
+
this.setState('connecting');
|
|
278
387
|
try {
|
|
279
388
|
const serverConfig = await this.fetchServerConfig();
|
|
280
389
|
if (serverConfig.kind === 'noop' || !serverConfig.endpoint) {
|
|
281
|
-
this
|
|
390
|
+
// Realtime is off for this workspace — nothing to retry or report.
|
|
391
|
+
this.episode = undefined;
|
|
392
|
+
this.setState('closed');
|
|
282
393
|
return;
|
|
283
394
|
}
|
|
284
395
|
const channels = this.channelsToSubscribe();
|
|
@@ -290,16 +401,30 @@ export class RealtimeClient {
|
|
|
290
401
|
return;
|
|
291
402
|
}
|
|
292
403
|
if (serverConfig.kind === 'centrifugo') {
|
|
293
|
-
const
|
|
404
|
+
const userToken = this.currentToken();
|
|
405
|
+
let auth;
|
|
406
|
+
try {
|
|
407
|
+
auth = await this.authorize(channels, userToken);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
// A 401/403 from /realtime/authorize is the same refusal AppSync
|
|
411
|
+
// reports as connection_error — retrying on a backoff can't fix it.
|
|
412
|
+
if (err instanceof RealtimeAuthRefusedError) {
|
|
413
|
+
await this.handleAuthRefusal(userToken, channels);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
294
418
|
this.openWebSocket(serverConfig.endpoint, auth);
|
|
295
419
|
return;
|
|
296
420
|
}
|
|
297
421
|
// Unknown protocol — close cleanly so consumers don't get stuck in
|
|
298
422
|
// 'connecting'. New transports must be added explicitly here.
|
|
299
|
-
this.
|
|
423
|
+
this.setState('closed');
|
|
300
424
|
}
|
|
301
|
-
catch {
|
|
302
|
-
this.
|
|
425
|
+
catch (err) {
|
|
426
|
+
this.beginTransient('setup_failed', `could not reach Bridge to set up the connection (${errorText(err)})`, 'warn');
|
|
427
|
+
this.setState('closed');
|
|
303
428
|
this.scheduleReconnect();
|
|
304
429
|
}
|
|
305
430
|
}
|
|
@@ -307,6 +432,7 @@ export class RealtimeClient {
|
|
|
307
432
|
async stop() {
|
|
308
433
|
this.stopped = true;
|
|
309
434
|
this.clearSubscribeAckTimer();
|
|
435
|
+
this.removeResumeListeners();
|
|
310
436
|
if (this.reconnectTimer) {
|
|
311
437
|
clearTimeout(this.reconnectTimer);
|
|
312
438
|
this.reconnectTimer = undefined;
|
|
@@ -320,7 +446,9 @@ export class RealtimeClient {
|
|
|
320
446
|
}
|
|
321
447
|
this.ws = undefined;
|
|
322
448
|
}
|
|
323
|
-
this.
|
|
449
|
+
this.episode = undefined;
|
|
450
|
+
this.clearRefusal();
|
|
451
|
+
this.setState('closed');
|
|
324
452
|
}
|
|
325
453
|
/** Read connection state. */
|
|
326
454
|
getState() {
|
|
@@ -356,8 +484,8 @@ export class RealtimeClient {
|
|
|
356
484
|
}
|
|
357
485
|
return (await res.json());
|
|
358
486
|
}
|
|
359
|
-
async authorize(channels) {
|
|
360
|
-
const token =
|
|
487
|
+
async authorize(channels, userToken) {
|
|
488
|
+
const token = userToken ?? this.cfg.apiKey;
|
|
361
489
|
const res = await this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/authorize`, {
|
|
362
490
|
method: 'POST',
|
|
363
491
|
headers: {
|
|
@@ -366,6 +494,9 @@ export class RealtimeClient {
|
|
|
366
494
|
},
|
|
367
495
|
body: JSON.stringify({ channels }),
|
|
368
496
|
});
|
|
497
|
+
if (res.status === 401 || res.status === 403) {
|
|
498
|
+
throw new RealtimeAuthRefusedError(res.status);
|
|
499
|
+
}
|
|
369
500
|
if (!res.ok) {
|
|
370
501
|
throw new Error(`realtime authorize failed: ${res.status}`);
|
|
371
502
|
}
|
|
@@ -377,7 +508,6 @@ export class RealtimeClient {
|
|
|
377
508
|
ws.onopen = () => {
|
|
378
509
|
if (this.ws !== ws)
|
|
379
510
|
return;
|
|
380
|
-
this.state = 'open';
|
|
381
511
|
this.reconnectDelayMs = this.cfg.reconnectBaseMs;
|
|
382
512
|
// Send connect with the signed token + channels. Centrifugo expects a
|
|
383
513
|
// command frame like `{ "connect": { "token": "..." }, "id": 1 }` and
|
|
@@ -389,12 +519,7 @@ export class RealtimeClient {
|
|
|
389
519
|
catch {
|
|
390
520
|
// ignore
|
|
391
521
|
}
|
|
392
|
-
|
|
393
|
-
this.onOpenHook?.();
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
// hook errors must not break the connection
|
|
397
|
-
}
|
|
522
|
+
this.markOpen();
|
|
398
523
|
};
|
|
399
524
|
ws.onmessage = (ev) => {
|
|
400
525
|
if (this.ws !== ws)
|
|
@@ -427,7 +552,8 @@ export class RealtimeClient {
|
|
|
427
552
|
// is from a stale socket. Don't flap state or fire hooks.
|
|
428
553
|
if (this.ws !== ws)
|
|
429
554
|
return;
|
|
430
|
-
this.
|
|
555
|
+
this.noteClose(ws);
|
|
556
|
+
this.setState('closed');
|
|
431
557
|
try {
|
|
432
558
|
this.onCloseHook?.();
|
|
433
559
|
}
|
|
@@ -461,8 +587,10 @@ export class RealtimeClient {
|
|
|
461
587
|
* `appsync-events.adapter.ts:87` and `appsync-authorizer.handler.ts:59`).
|
|
462
588
|
*
|
|
463
589
|
* Anonymous flow: `getAuthToken()` returns undefined → Authorization sent as
|
|
464
|
-
*
|
|
465
|
-
*
|
|
590
|
+
* the marker `REALTIME_ANONYMOUS_TOKEN` (see buildAppSyncAuthHeader for why
|
|
591
|
+
* it can't be empty). The Lambda authorizer treats exactly that value as "no
|
|
592
|
+
* token": CONNECT allowed, `app:<appId>` channels origin-checked against the
|
|
593
|
+
* app's allowedOrigins, everything else denied `no_token`.
|
|
466
594
|
*/
|
|
467
595
|
openAppSyncWebSocket(endpoint, channels) {
|
|
468
596
|
const { url, httpHost } = normalizeAppSyncEndpoint(endpoint);
|
|
@@ -471,7 +599,10 @@ export class RealtimeClient {
|
|
|
471
599
|
// server-side validation uses this to verify the connection — sending
|
|
472
600
|
// the realtime host instead produces a silent close 1000 right after
|
|
473
601
|
// the WS upgrade succeeds.
|
|
474
|
-
|
|
602
|
+
// Captured once: a refusal must be diagnosed against the token that was
|
|
603
|
+
// actually presented, not whatever the host holds by the time we look.
|
|
604
|
+
const token = this.currentToken();
|
|
605
|
+
const authHeader = buildAppSyncAuthHeader(token, httpHost);
|
|
475
606
|
const headerProtocol = `header-${base64urlEncode(JSON.stringify(authHeader))}`;
|
|
476
607
|
const ws = this.cfg.websocketFactory(url, [APPSYNC_WS_PROTOCOL, headerProtocol]);
|
|
477
608
|
this.ws = ws;
|
|
@@ -580,14 +711,8 @@ export class RealtimeClient {
|
|
|
580
711
|
this.ackedChannels.add(channel);
|
|
581
712
|
}
|
|
582
713
|
if (this.state !== 'open') {
|
|
583
|
-
this.state = 'open';
|
|
584
714
|
this.clearSubscribeAckTimer();
|
|
585
|
-
|
|
586
|
-
this.onOpenHook?.();
|
|
587
|
-
}
|
|
588
|
-
catch {
|
|
589
|
-
// hook errors must not break the connection.
|
|
590
|
-
}
|
|
715
|
+
this.markOpen();
|
|
591
716
|
}
|
|
592
717
|
break;
|
|
593
718
|
}
|
|
@@ -600,6 +725,11 @@ export class RealtimeClient {
|
|
|
600
725
|
this.pendingSubscribes.delete(id);
|
|
601
726
|
this.failedChannels.set(channel, describeAppSyncError(frame));
|
|
602
727
|
this.cfg.logger.error(`realtime: AppSync rejected subscription to '${channel}' — ${describeAppSyncError(frame)}. Live updates will not arrive on this channel.`);
|
|
728
|
+
// AppSync never says why; ask Bridge once per channel per connection.
|
|
729
|
+
if (isAppSyncAuthRefusal(frame) && this.cfg.diagnose && !this.diagnosedChannels.has(channel)) {
|
|
730
|
+
this.diagnosedChannels.add(channel);
|
|
731
|
+
void this.diagnoseChannelRefusal(ws, token, channel);
|
|
732
|
+
}
|
|
603
733
|
// Every channel rejected → connected but deaf.
|
|
604
734
|
if (this.pendingSubscribes.size === 0 && this.ackedChannels.size === 0) {
|
|
605
735
|
this.clearSubscribeAckTimer();
|
|
@@ -609,8 +739,20 @@ export class RealtimeClient {
|
|
|
609
739
|
}
|
|
610
740
|
case 'connection_error':
|
|
611
741
|
case 'error':
|
|
612
|
-
//
|
|
613
|
-
|
|
742
|
+
// TBP-643 — an auth refusal is not a blip. Reconnecting with the same
|
|
743
|
+
// token can only be refused again; before this branch existed the
|
|
744
|
+
// client did exactly that on a backoff loop forever, logging a
|
|
745
|
+
// reason-less line each time. AppSync never relays the authorizer's
|
|
746
|
+
// reason, so detect by errorType/errorCode and work out the "why"
|
|
747
|
+
// ourselves (pre-checks → one refresh → one diagnose).
|
|
748
|
+
if (isAppSyncAuthRefusal(frame)) {
|
|
749
|
+
this.detachSocket(ws, `appsync:${type}`);
|
|
750
|
+
void this.handleAuthRefusal(token, channels);
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
// Anything else is a connection-level fault worth retrying. Logged
|
|
754
|
+
// once per episode (beginTransient), not once per attempt.
|
|
755
|
+
this.beginTransient('server_error', `the realtime server reported ${type}: ${describeAppSyncError(frame)}`, 'error');
|
|
614
756
|
try {
|
|
615
757
|
ws.close(1011, `appsync:${type}`);
|
|
616
758
|
}
|
|
@@ -627,7 +769,8 @@ export class RealtimeClient {
|
|
|
627
769
|
ws.onclose = () => {
|
|
628
770
|
if (this.ws !== ws)
|
|
629
771
|
return;
|
|
630
|
-
this.
|
|
772
|
+
this.noteClose(ws);
|
|
773
|
+
this.setState('closed');
|
|
631
774
|
this.resetSubscribeTracking();
|
|
632
775
|
try {
|
|
633
776
|
this.onCloseHook?.();
|
|
@@ -647,6 +790,43 @@ export class RealtimeClient {
|
|
|
647
790
|
this.pendingSubscribes.clear();
|
|
648
791
|
this.ackedChannels.clear();
|
|
649
792
|
this.failedChannels.clear();
|
|
793
|
+
this.diagnosedChannels.clear();
|
|
794
|
+
this.channelRefusal = undefined;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* TBP-669 — explain a channel AppSync refused at SUBSCRIBE. The connection
|
|
798
|
+
* stays up (see `subscribe_error`); this only attaches Bridge's reason to
|
|
799
|
+
* the status and logs it once, with the fix when the client knows it.
|
|
800
|
+
*/
|
|
801
|
+
async diagnoseChannelRefusal(ws, token, channel) {
|
|
802
|
+
const ref = newRef();
|
|
803
|
+
const verdict = await this.diagnoseRefusal(token, [channel]);
|
|
804
|
+
if (!verdict || this.ws !== ws || this.stopped)
|
|
805
|
+
return;
|
|
806
|
+
const refusal = {
|
|
807
|
+
...verdict,
|
|
808
|
+
channel,
|
|
809
|
+
ref,
|
|
810
|
+
docsUrl: `${this.cfg.docsBaseUrl}#${verdict.reason}`,
|
|
811
|
+
};
|
|
812
|
+
this.channelRefusal = refusal;
|
|
813
|
+
this.publishStatus();
|
|
814
|
+
const message = this.formatChannelRefusal(refusal);
|
|
815
|
+
const key = `${channel}|${verdict.reason}|${verdict.side}`;
|
|
816
|
+
if (key === this.lastChannelRefusalKey) {
|
|
817
|
+
this.cfg.logger.debug(message);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.lastChannelRefusalKey = key;
|
|
821
|
+
this.cfg.logger.error(message);
|
|
822
|
+
}
|
|
823
|
+
formatChannelRefusal(r) {
|
|
824
|
+
const hint = refusalHint(r.reason);
|
|
825
|
+
return [
|
|
826
|
+
`[bridge] Live updates are OFF on channel '${r.channel}' — Bridge refused the subscription (${r.reason}).`,
|
|
827
|
+
hint ? ` Fix: ${hint}` : ` See the docs entry below for what '${r.reason}' means for this session.`,
|
|
828
|
+
` ${r.docsUrl} · ref ${r.ref}`,
|
|
829
|
+
].join('\n');
|
|
650
830
|
}
|
|
651
831
|
clearSubscribeAckTimer() {
|
|
652
832
|
if (this.subscribeAckTimer) {
|
|
@@ -661,7 +841,13 @@ export class RealtimeClient {
|
|
|
661
841
|
* polling or warn the user.
|
|
662
842
|
*/
|
|
663
843
|
markDegraded() {
|
|
664
|
-
|
|
844
|
+
// The transport did come back; degraded has its own error log above, so a
|
|
845
|
+
// pending "restored" line would be misleading — drop the episode quietly.
|
|
846
|
+
this.episode = undefined;
|
|
847
|
+
const alreadyDegraded = this.state === 'degraded';
|
|
848
|
+
this.setState('degraded');
|
|
849
|
+
if (!alreadyDegraded)
|
|
850
|
+
this.sendStatusReport({ state: 'degraded', reason: 'no_channel_accepted' });
|
|
665
851
|
try {
|
|
666
852
|
this.onDegradedHook?.();
|
|
667
853
|
}
|
|
@@ -788,12 +974,553 @@ export class RealtimeClient {
|
|
|
788
974
|
return;
|
|
789
975
|
this.reconnectTimer = setTimeout(() => {
|
|
790
976
|
this.reconnectTimer = undefined;
|
|
791
|
-
this.
|
|
792
|
-
void this.start();
|
|
977
|
+
this.fireReconnect();
|
|
793
978
|
}, this.reconnectDelayMs);
|
|
794
979
|
if (this.reconnectTimer?.unref)
|
|
795
980
|
this.reconnectTimer.unref();
|
|
796
981
|
}
|
|
982
|
+
fireReconnect() {
|
|
983
|
+
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.cfg.reconnectMaxMs);
|
|
984
|
+
if (this.episode)
|
|
985
|
+
this.episode.attempts++;
|
|
986
|
+
void this.start();
|
|
987
|
+
}
|
|
988
|
+
// ── Status + fault reporting (TBP-643) ────────────────────────────────────
|
|
989
|
+
setState(state) {
|
|
990
|
+
this.state = state;
|
|
991
|
+
this.publishStatus();
|
|
992
|
+
}
|
|
993
|
+
publishStatus() {
|
|
994
|
+
const detail = this.statusDetail();
|
|
995
|
+
const prev = this.status;
|
|
996
|
+
if (prev.state === this.state &&
|
|
997
|
+
prev.reason === detail.reason &&
|
|
998
|
+
prev.side === detail.side &&
|
|
999
|
+
prev.hint === detail.hint &&
|
|
1000
|
+
prev.retrying === detail.retrying &&
|
|
1001
|
+
prev.ref === detail.ref) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
this.status = { state: this.state, ...detail, since: Date.now() };
|
|
1005
|
+
if (!this.onStatusChangeHook)
|
|
1006
|
+
return;
|
|
1007
|
+
try {
|
|
1008
|
+
this.onStatusChangeHook({ ...this.status });
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
// a consumer's indicator must never break the connection.
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
statusDetail() {
|
|
1015
|
+
if (this.state === 'unauthorized' && this.refusal) {
|
|
1016
|
+
const r = this.refusal;
|
|
1017
|
+
return withHint({ reason: r.reason, side: r.side, retrying: false, docsUrl: r.docsUrl, ref: r.ref });
|
|
1018
|
+
}
|
|
1019
|
+
// A channel Bridge refused and explained (TBP-669): all of them
|
|
1020
|
+
// (degraded) or some of them (open — the others still deliver).
|
|
1021
|
+
const cr = this.channelRefusal;
|
|
1022
|
+
if (cr && (this.state === 'degraded' || this.state === 'open')) {
|
|
1023
|
+
return withHint({ reason: cr.reason, side: cr.side, retrying: false, docsUrl: cr.docsUrl, ref: cr.ref });
|
|
1024
|
+
}
|
|
1025
|
+
if (this.state === 'degraded')
|
|
1026
|
+
return { reason: 'no_channel_accepted', retrying: false };
|
|
1027
|
+
const ep = this.episode;
|
|
1028
|
+
if (ep?.kind === 'transient') {
|
|
1029
|
+
return { reason: ep.reason, side: 'network', retrying: true, ref: ep.ref };
|
|
1030
|
+
}
|
|
1031
|
+
// Auth episode in flight: refreshing or diagnosing — not given up yet.
|
|
1032
|
+
if (ep?.kind === 'auth')
|
|
1033
|
+
return { reason: ep.reason, retrying: true, ref: ep.ref };
|
|
1034
|
+
return { retrying: false };
|
|
1035
|
+
}
|
|
1036
|
+
/** The token to present — see `freshToken`. */
|
|
1037
|
+
currentToken() {
|
|
1038
|
+
const host = this.cfg.getAuthToken?.();
|
|
1039
|
+
if (this.freshToken !== undefined) {
|
|
1040
|
+
if (host === this.staleToken)
|
|
1041
|
+
return this.freshToken;
|
|
1042
|
+
// The host has caught up (or moved on) — its value wins from here.
|
|
1043
|
+
this.freshToken = undefined;
|
|
1044
|
+
this.staleToken = undefined;
|
|
1045
|
+
}
|
|
1046
|
+
return host;
|
|
1047
|
+
}
|
|
1048
|
+
/** The transport is usable. Closes any episode, logging recovery if we logged the fault. */
|
|
1049
|
+
markOpen() {
|
|
1050
|
+
const ep = this.episode;
|
|
1051
|
+
this.episode = undefined;
|
|
1052
|
+
this.lastRefusalKey = undefined;
|
|
1053
|
+
this.setState('open');
|
|
1054
|
+
const now = Date.now();
|
|
1055
|
+
if (this.lastOpenReportAt === undefined || now - this.lastOpenReportAt >= STATUS_REPORT_OPEN_INTERVAL_MS) {
|
|
1056
|
+
if (this.sendStatusReport({ state: 'open' }))
|
|
1057
|
+
this.lastOpenReportAt = now;
|
|
1058
|
+
}
|
|
1059
|
+
if (ep?.kind === 'transient' && ep.logLevel) {
|
|
1060
|
+
const n = Math.max(ep.attempts, 1);
|
|
1061
|
+
this.cfg.logger[ep.logLevel](`[bridge] Live updates restored after ${n} attempt${n === 1 ? '' : 's'}.`);
|
|
1062
|
+
}
|
|
1063
|
+
try {
|
|
1064
|
+
this.onOpenHook?.();
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
// hook errors must not break the connection.
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/** Called from onclose of the CURRENT socket: an unplanned close starts a transient episode. */
|
|
1071
|
+
noteClose(ws) {
|
|
1072
|
+
if (this.expectedCloseWs === ws) {
|
|
1073
|
+
this.expectedCloseWs = undefined;
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
this.beginTransient('connection_lost', 'the realtime connection dropped', 'warn');
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Open a transient episode if none is running, and log its start ONCE.
|
|
1080
|
+
* Server-reported errors log at `error` (they used to, and they are real
|
|
1081
|
+
* faults); plain drops and fetch failures log at `warn` — sleep/wake and
|
|
1082
|
+
* wifi changes cause them constantly and they fix themselves, so they must
|
|
1083
|
+
* not paint every end-user console red.
|
|
1084
|
+
*/
|
|
1085
|
+
beginTransient(reason, detail, level) {
|
|
1086
|
+
if (this.episode)
|
|
1087
|
+
return;
|
|
1088
|
+
const ref = newRef();
|
|
1089
|
+
this.episode = {
|
|
1090
|
+
kind: 'transient',
|
|
1091
|
+
ref,
|
|
1092
|
+
attempts: 0,
|
|
1093
|
+
reason,
|
|
1094
|
+
logLevel: level,
|
|
1095
|
+
refreshed: false,
|
|
1096
|
+
diagnosed: false,
|
|
1097
|
+
};
|
|
1098
|
+
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}`);
|
|
1099
|
+
}
|
|
1100
|
+
/** Drop a socket without letting its onclose schedule a reconnect. */
|
|
1101
|
+
detachSocket(ws, reason) {
|
|
1102
|
+
if (this.ws === ws)
|
|
1103
|
+
this.ws = undefined;
|
|
1104
|
+
this.resetSubscribeTracking();
|
|
1105
|
+
try {
|
|
1106
|
+
ws.close(1011, reason);
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
// ignore
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
this.onCloseHook?.();
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
// hook errors must not break refusal handling.
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Bridge refused `token` (TBP-643). Policy, in order:
|
|
1120
|
+
* a. client-side pre-checks on the token — cheap, and they name the fix;
|
|
1121
|
+
* b. one host refresh + an immediate reconnect, if the host gave us a hook;
|
|
1122
|
+
* c. if still refused and the pre-checks found nothing, ask Bridge once;
|
|
1123
|
+
* d. park in 'unauthorized', stop reconnecting, log ONE message.
|
|
1124
|
+
* Resumes on reauthorize(), a changed token on start(), `online`, or the
|
|
1125
|
+
* tab becoming visible.
|
|
1126
|
+
*/
|
|
1127
|
+
async handleAuthRefusal(token, channels) {
|
|
1128
|
+
if (this.stopped)
|
|
1129
|
+
return;
|
|
1130
|
+
if (this.reconnectTimer) {
|
|
1131
|
+
clearTimeout(this.reconnectTimer);
|
|
1132
|
+
this.reconnectTimer = undefined;
|
|
1133
|
+
}
|
|
1134
|
+
let ep = this.episode;
|
|
1135
|
+
if (!ep || ep.kind !== 'auth') {
|
|
1136
|
+
ep = {
|
|
1137
|
+
kind: 'auth',
|
|
1138
|
+
ref: newRef(),
|
|
1139
|
+
attempts: 0,
|
|
1140
|
+
reason: 'refused',
|
|
1141
|
+
refreshed: false,
|
|
1142
|
+
diagnosed: false,
|
|
1143
|
+
};
|
|
1144
|
+
this.episode = ep;
|
|
1145
|
+
}
|
|
1146
|
+
this.setState('connecting');
|
|
1147
|
+
if (this.cfg.refreshAuthToken && !ep.refreshed) {
|
|
1148
|
+
ep.refreshed = true;
|
|
1149
|
+
let fresh;
|
|
1150
|
+
try {
|
|
1151
|
+
fresh = await this.cfg.refreshAuthToken();
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
fresh = undefined;
|
|
1155
|
+
}
|
|
1156
|
+
if (this.episode !== ep || this.stopped)
|
|
1157
|
+
return;
|
|
1158
|
+
// Same token back = nothing to retry with; fall through to diagnosis.
|
|
1159
|
+
if (fresh && fresh !== token) {
|
|
1160
|
+
this.freshToken = fresh;
|
|
1161
|
+
this.staleToken = this.cfg.getAuthToken?.();
|
|
1162
|
+
this.setState('closed');
|
|
1163
|
+
await this.start();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
let verdict = this.precheckToken(token, channels);
|
|
1168
|
+
if (!verdict && this.cfg.diagnose && !ep.diagnosed) {
|
|
1169
|
+
ep.diagnosed = true;
|
|
1170
|
+
verdict = await this.diagnoseRefusal(token, channels);
|
|
1171
|
+
if (this.episode !== ep || this.stopped)
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
// Nothing explained it. With a token: Bridge-issued, unexpired, right
|
|
1175
|
+
// environment and app, yet refused — Bridge's problem, not the app's.
|
|
1176
|
+
// Without one: the server would not take an anonymous connect (e.g. an
|
|
1177
|
+
// authorizer that predates the anonymous marker). That is not a fault in
|
|
1178
|
+
// the app either, but "a problem on Bridge's side" would send developers
|
|
1179
|
+
// chasing an outage — it gets its own reason and wording, and the parked
|
|
1180
|
+
// client resumes as soon as a user signs in.
|
|
1181
|
+
const fallback = token
|
|
1182
|
+
? { reason: 'refused', side: 'bridge' }
|
|
1183
|
+
: { reason: 'anonymous_refused', side: 'bridge' };
|
|
1184
|
+
this.enterUnauthorized(token, verdict ?? fallback, ep);
|
|
1185
|
+
}
|
|
1186
|
+
/** Explain a refusal from the token alone. Decodes without verifying — this is diagnosis, not auth. */
|
|
1187
|
+
precheckToken(token, channels) {
|
|
1188
|
+
if (!token) {
|
|
1189
|
+
// Anonymous is a legitimate state for app-only channels (`app:<id>`).
|
|
1190
|
+
// It is only a fault when a channel needs a user — workspace, user,
|
|
1191
|
+
// integration: anything that is not `app:`.
|
|
1192
|
+
return channels.some((c) => !c.startsWith('app:'))
|
|
1193
|
+
? { reason: 'no_token', side: 'app' }
|
|
1194
|
+
: undefined;
|
|
1195
|
+
}
|
|
1196
|
+
const claims = decodeJwtPayload(token);
|
|
1197
|
+
if (!claims)
|
|
1198
|
+
return { reason: 'malformed', side: 'app' };
|
|
1199
|
+
const expectedIssuer = `${this.cfg.apiBaseUrl}/auth`;
|
|
1200
|
+
if (typeof claims.iss === 'string' && !claims.iss.startsWith(expectedIssuer)) {
|
|
1201
|
+
return { reason: 'wrong_environment', side: 'config', tokenIssuer: claims.iss };
|
|
1202
|
+
}
|
|
1203
|
+
if (this.cfg.appId && typeof claims.aid === 'string' && claims.aid !== this.cfg.appId) {
|
|
1204
|
+
return { reason: 'wrong_app', side: 'config', tokenAppId: claims.aid };
|
|
1205
|
+
}
|
|
1206
|
+
if (typeof claims.exp === 'number' && claims.exp * 1000 <= Date.now()) {
|
|
1207
|
+
return { reason: 'expired', side: 'app' };
|
|
1208
|
+
}
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Ask Bridge why it refused (contract: `POST /realtime/diagnose` →
|
|
1213
|
+
* `{ ok, reason, side }`, sharing the authorizer's classifier). The endpoint
|
|
1214
|
+
* may not exist yet — any failure returns undefined and the caller falls
|
|
1215
|
+
* back to 'refused'.
|
|
1216
|
+
*/
|
|
1217
|
+
async diagnoseRefusal(token, channels) {
|
|
1218
|
+
// Only headers bridge-api's CORS allow-list accepts (TBP-669). This runs
|
|
1219
|
+
// in the browser, and one unlisted header fails the preflight, so the
|
|
1220
|
+
// request never leaves (net::ERR_FAILED) and the verdict is lost. The
|
|
1221
|
+
// episode ref used to travel as `x-bridge-realtime-ref`; it stays
|
|
1222
|
+
// client-side now (status + console line). It can't move to the body
|
|
1223
|
+
// either: the diagnose DTO answers an unknown property with 400.
|
|
1224
|
+
// Pinned by cors-allowed-headers.test.ts.
|
|
1225
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1226
|
+
if (token)
|
|
1227
|
+
headers.Authorization = `Bearer ${token}`;
|
|
1228
|
+
if (this.cfg.appId)
|
|
1229
|
+
headers['x-app-id'] = this.cfg.appId;
|
|
1230
|
+
let timer;
|
|
1231
|
+
try {
|
|
1232
|
+
const timeout = new Promise((_, reject) => {
|
|
1233
|
+
timer = setTimeout(() => reject(new Error('diagnose timed out')), DIAGNOSE_TIMEOUT_MS);
|
|
1234
|
+
});
|
|
1235
|
+
const res = await Promise.race([
|
|
1236
|
+
this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/realtime/diagnose`, {
|
|
1237
|
+
method: 'POST',
|
|
1238
|
+
headers,
|
|
1239
|
+
body: JSON.stringify({ channels }),
|
|
1240
|
+
}),
|
|
1241
|
+
timeout,
|
|
1242
|
+
]);
|
|
1243
|
+
if (!res.ok)
|
|
1244
|
+
return undefined;
|
|
1245
|
+
const body = (await res.json());
|
|
1246
|
+
// Bridge sees nothing wrong — no verdict; the caller's fallback applies.
|
|
1247
|
+
if (body?.ok === true)
|
|
1248
|
+
return undefined;
|
|
1249
|
+
const reason = typeof body?.reason === 'string' && /^[a-z0-9_]+$/.test(body.reason) ? body.reason : 'refused';
|
|
1250
|
+
const side = body?.side === 'app' || body?.side === 'config' || body?.side === 'bridge' ? body.side : 'bridge';
|
|
1251
|
+
return { reason, side: sideFor(reason, side) };
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
return undefined;
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
if (timer)
|
|
1258
|
+
clearTimeout(timer);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
enterUnauthorized(token, verdict, ep) {
|
|
1262
|
+
const docsUrl = `${this.cfg.docsBaseUrl}#${verdict.reason}`;
|
|
1263
|
+
this.refusal = { ...verdict, token, ref: ep.ref, docsUrl };
|
|
1264
|
+
this.episode = undefined;
|
|
1265
|
+
this.setState('unauthorized');
|
|
1266
|
+
this.startParkedTokenCheck();
|
|
1267
|
+
const message = this.formatRefusal(this.refusal);
|
|
1268
|
+
const key = `${token ?? ''}|${verdict.reason}|${verdict.side}`;
|
|
1269
|
+
if (key === this.lastRefusalKey) {
|
|
1270
|
+
this.cfg.logger.debug(message);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
this.lastRefusalKey = key;
|
|
1274
|
+
this.cfg.logger.error(message);
|
|
1275
|
+
// Reported under the same dedupe as the log: a resume (tab refocus,
|
|
1276
|
+
// `online`) that ends in the identical refusal is not new health news.
|
|
1277
|
+
this.sendStatusReport({ state: 'unauthorized', reason: verdict.reason, side: verdict.side, ref: ep.ref });
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* TBP-645 — fire-and-forget health report. Returns whether a report was
|
|
1281
|
+
* dispatched (false when opted out, or there is no appId to attribute it
|
|
1282
|
+
* to). Never awaited by the caller and never throws: this runs on the
|
|
1283
|
+
* connect path, and telemetry must not be able to hurt the connection. The
|
|
1284
|
+
* endpoint may not exist yet — every response, 404 included, is ignored.
|
|
1285
|
+
*/
|
|
1286
|
+
sendStatusReport(report) {
|
|
1287
|
+
const appId = this.cfg.appId;
|
|
1288
|
+
if (!this.cfg.reportStatus || !appId || typeof this.cfg.fetchFn !== 'function')
|
|
1289
|
+
return false;
|
|
1290
|
+
try {
|
|
1291
|
+
void this.postStatusReport(appId, report);
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
// never
|
|
1295
|
+
}
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
async postStatusReport(appId, report) {
|
|
1299
|
+
let timer;
|
|
1300
|
+
try {
|
|
1301
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1302
|
+
const AC = globalThis.AbortController;
|
|
1303
|
+
const controller = typeof AC === 'function' ? new AC() : undefined;
|
|
1304
|
+
const timeout = new Promise((resolve) => {
|
|
1305
|
+
timer = setTimeout(() => {
|
|
1306
|
+
try {
|
|
1307
|
+
controller?.abort();
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
// ignore
|
|
1311
|
+
}
|
|
1312
|
+
resolve();
|
|
1313
|
+
}, STATUS_REPORT_TIMEOUT_MS);
|
|
1314
|
+
if (timer?.unref)
|
|
1315
|
+
timer.unref();
|
|
1316
|
+
});
|
|
1317
|
+
const body = { state: report.state };
|
|
1318
|
+
if (report.reason)
|
|
1319
|
+
body.reason = report.reason;
|
|
1320
|
+
if (report.side)
|
|
1321
|
+
body.side = report.side;
|
|
1322
|
+
if (report.ref)
|
|
1323
|
+
body.ref = report.ref;
|
|
1324
|
+
// Deferred so a synchronously-throwing fetch lands in the handler too.
|
|
1325
|
+
const request = Promise.resolve().then(() => this.cfg.fetchFn(`${this.cfg.apiBaseUrl}/account/auth/realtime-status`, {
|
|
1326
|
+
method: 'POST',
|
|
1327
|
+
headers: { 'Content-Type': 'application/json', 'x-app-id': appId },
|
|
1328
|
+
body: JSON.stringify(body),
|
|
1329
|
+
signal: controller?.signal,
|
|
1330
|
+
}));
|
|
1331
|
+
await Promise.race([request.then(noop, noop), timeout]);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
// swallowed — see sendStatusReport
|
|
1335
|
+
}
|
|
1336
|
+
finally {
|
|
1337
|
+
if (timer)
|
|
1338
|
+
clearTimeout(timer);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
clearRefusal() {
|
|
1342
|
+
this.refusal = undefined;
|
|
1343
|
+
if (this.parkedTokenTimer) {
|
|
1344
|
+
clearInterval(this.parkedTokenTimer);
|
|
1345
|
+
this.parkedTokenTimer = undefined;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* A parked client must resume when the host's token changes — most
|
|
1350
|
+
* importantly when a signed-out session signs in (undefined → token).
|
|
1351
|
+
* Framework SDKs only call reauthorize() when one token REPLACES another
|
|
1352
|
+
* (TBP-644 fixes that), so auth-core can't rely on being told.
|
|
1353
|
+
*
|
|
1354
|
+
* Chosen over a `notifyAuthTokenChanged()` API because it needs no host
|
|
1355
|
+
* change — the missing host call is the bug. It is cheap and bounded: it
|
|
1356
|
+
* runs only while parked, calls the synchronous `getAuthToken()` getter (no
|
|
1357
|
+
* network), and resumes only on a token DIFFERENT from the refused one, so
|
|
1358
|
+
* an unchanged session can never turn it into a reconnect loop.
|
|
1359
|
+
*/
|
|
1360
|
+
startParkedTokenCheck() {
|
|
1361
|
+
if (this.parkedTokenTimer || !this.cfg.getAuthToken)
|
|
1362
|
+
return;
|
|
1363
|
+
this.parkedTokenTimer = setInterval(() => {
|
|
1364
|
+
if (this.state !== 'unauthorized' || this.stopped) {
|
|
1365
|
+
this.clearRefusal();
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
if (this.currentToken() !== this.refusal?.token)
|
|
1369
|
+
void this.start();
|
|
1370
|
+
}, PARKED_TOKEN_CHECK_MS);
|
|
1371
|
+
if (this.parkedTokenTimer?.unref)
|
|
1372
|
+
this.parkedTokenTimer.unref();
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* The one message a developer gets per refused episode. Product terms, whose
|
|
1376
|
+
* side it is, what still works, one next step, a docs link and a ref.
|
|
1377
|
+
*/
|
|
1378
|
+
formatRefusal(r) {
|
|
1379
|
+
const stopped = ' Stopped: plan & entitlement changes, feature-flag flips, the plan-changed token refresh. They appear only after a reload.';
|
|
1380
|
+
const stillFine = ' Still fine: every API call your app makes.';
|
|
1381
|
+
const footer = ` ${r.docsUrl} · ref ${r.ref}`;
|
|
1382
|
+
if (r.reason === 'anonymous_refused') {
|
|
1383
|
+
return [
|
|
1384
|
+
`[bridge] Live updates are unavailable before sign-in — Bridge did not accept this signed-out session's realtime connection (${r.reason}).`,
|
|
1385
|
+
' They start automatically once a user signs in. Until then, feature-flag flips appear only after a reload.',
|
|
1386
|
+
stillFine,
|
|
1387
|
+
' Nothing to change in your code.',
|
|
1388
|
+
footer,
|
|
1389
|
+
].join('\n');
|
|
1390
|
+
}
|
|
1391
|
+
if (r.side === 'bridge') {
|
|
1392
|
+
return [
|
|
1393
|
+
"[bridge] Live updates are OFF — this is a problem on Bridge's side, not in your app.",
|
|
1394
|
+
` Bridge refused this session's realtime connection (${r.reason}) although the session token checks out.`,
|
|
1395
|
+
stopped,
|
|
1396
|
+
stillFine,
|
|
1397
|
+
` Nothing to change in your code — include ref ${r.ref} if you contact support.`,
|
|
1398
|
+
footer,
|
|
1399
|
+
].join('\n');
|
|
1400
|
+
}
|
|
1401
|
+
if (r.reason === 'origin_not_allowed') {
|
|
1402
|
+
return [
|
|
1403
|
+
`[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}): this page's origin is not in the app's allowed origins.`,
|
|
1404
|
+
stopped,
|
|
1405
|
+
` Fix: ${originNotAllowedHint()}`,
|
|
1406
|
+
footer,
|
|
1407
|
+
].join('\n');
|
|
1408
|
+
}
|
|
1409
|
+
if (r.side === 'config') {
|
|
1410
|
+
const apiHost = hostOf(this.cfg.apiBaseUrl);
|
|
1411
|
+
let mismatch;
|
|
1412
|
+
let fix;
|
|
1413
|
+
if (r.reason === 'wrong_environment' && r.tokenIssuer) {
|
|
1414
|
+
const tokenHost = hostOf(r.tokenIssuer);
|
|
1415
|
+
mismatch = ` This app's Bridge API host is ${apiHost}, but the signed-in session's token was issued by ${tokenHost}.`;
|
|
1416
|
+
fix = ` Fix: point the Bridge API base URL setting (apiBaseUrl) at ${tokenHost}, or sign users in against ${apiHost} — both must be the same environment.`;
|
|
1417
|
+
}
|
|
1418
|
+
else if (r.reason === 'wrong_app' && r.tokenAppId) {
|
|
1419
|
+
mismatch = ` This app is configured with app id ${this.cfg.appId}, but the signed-in session's token belongs to app ${r.tokenAppId}.`;
|
|
1420
|
+
fix = ` Fix: set the appId setting to ${r.tokenAppId}, or sign users in through app ${this.cfg.appId} — both must name the same app.`;
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
mismatch = ` This app's Bridge settings (API host ${apiHost}${this.cfg.appId ? `, app id ${this.cfg.appId}` : ''}) don't match the signed-in session.`;
|
|
1424
|
+
fix = ` Fix: correct the Bridge setting the docs entry below names for '${r.reason}'.`;
|
|
1425
|
+
}
|
|
1426
|
+
return [
|
|
1427
|
+
`[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}): your Bridge settings don't match the session.`,
|
|
1428
|
+
mismatch,
|
|
1429
|
+
stopped,
|
|
1430
|
+
fix,
|
|
1431
|
+
footer,
|
|
1432
|
+
].join('\n');
|
|
1433
|
+
}
|
|
1434
|
+
return [
|
|
1435
|
+
`[bridge] Live updates are OFF — Bridge refused this session's realtime connection (${r.reason}).`,
|
|
1436
|
+
stopped,
|
|
1437
|
+
stillFine,
|
|
1438
|
+
` Fix: ${appFix(r.reason)}.`,
|
|
1439
|
+
footer,
|
|
1440
|
+
].join('\n');
|
|
1441
|
+
}
|
|
1442
|
+
// ── Resume triggers (browser only; guarded so Node/SSR never touches them) ──
|
|
1443
|
+
onOnline = () => this.nudge();
|
|
1444
|
+
onVisibilityChange = () => {
|
|
1445
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1446
|
+
if (globalThis.document?.visibilityState === 'visible')
|
|
1447
|
+
this.nudge();
|
|
1448
|
+
};
|
|
1449
|
+
installResumeListeners() {
|
|
1450
|
+
if (this.resumeListenersInstalled)
|
|
1451
|
+
return;
|
|
1452
|
+
this.resumeListenersInstalled = true;
|
|
1453
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1454
|
+
const g = globalThis;
|
|
1455
|
+
if (typeof g.addEventListener === 'function')
|
|
1456
|
+
g.addEventListener('online', this.onOnline);
|
|
1457
|
+
if (typeof g.document?.addEventListener === 'function') {
|
|
1458
|
+
g.document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
removeResumeListeners() {
|
|
1462
|
+
if (!this.resumeListenersInstalled)
|
|
1463
|
+
return;
|
|
1464
|
+
this.resumeListenersInstalled = false;
|
|
1465
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1466
|
+
const g = globalThis;
|
|
1467
|
+
if (typeof g.removeEventListener === 'function')
|
|
1468
|
+
g.removeEventListener('online', this.onOnline);
|
|
1469
|
+
if (typeof g.document?.removeEventListener === 'function') {
|
|
1470
|
+
g.document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* The network came back or the user returned to the tab: the conditions
|
|
1475
|
+
* behind a refusal may have changed (host refreshed the session, clock
|
|
1476
|
+
* caught up), so a parked client gets one new episode; a client waiting out
|
|
1477
|
+
* a backoff tries now instead.
|
|
1478
|
+
*/
|
|
1479
|
+
nudge() {
|
|
1480
|
+
if (!this.cfg.enabled || this.stopped)
|
|
1481
|
+
return;
|
|
1482
|
+
if (this.state === 'unauthorized') {
|
|
1483
|
+
this.clearRefusal();
|
|
1484
|
+
this.setState('closed');
|
|
1485
|
+
void this.start();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (this.reconnectTimer) {
|
|
1489
|
+
clearTimeout(this.reconnectTimer);
|
|
1490
|
+
this.reconnectTimer = undefined;
|
|
1491
|
+
this.fireReconnect();
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Whose move a diagnosed reason is. The server's answer, except where the
|
|
1497
|
+
* client knows better: `/realtime/diagnose` reports every channel-check
|
|
1498
|
+
* failure as side `app`, but an origin missing from the app's allowed origins
|
|
1499
|
+
* is fixed in the app's Bridge settings, not in its code (TBP-669).
|
|
1500
|
+
*/
|
|
1501
|
+
function sideFor(reason, serverSide) {
|
|
1502
|
+
return reason === 'origin_not_allowed' ? 'config' : serverSide;
|
|
1503
|
+
}
|
|
1504
|
+
/** The one-sentence fix for a reason, when the client can name it. */
|
|
1505
|
+
function refusalHint(reason) {
|
|
1506
|
+
return reason === 'origin_not_allowed' ? originNotAllowedHint(currentOrigin()) : undefined;
|
|
1507
|
+
}
|
|
1508
|
+
function withHint(detail) {
|
|
1509
|
+
const hint = detail.reason ? refusalHint(detail.reason) : undefined;
|
|
1510
|
+
return hint ? { ...detail, hint } : detail;
|
|
1511
|
+
}
|
|
1512
|
+
/** Reason-specific next step for app-side refusals. */
|
|
1513
|
+
function appFix(reason) {
|
|
1514
|
+
switch (reason) {
|
|
1515
|
+
case 'no_token':
|
|
1516
|
+
return "start live updates after sign-in, or pass getAuthToken so the client can read the session's access token";
|
|
1517
|
+
case 'expired':
|
|
1518
|
+
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()';
|
|
1519
|
+
case 'malformed':
|
|
1520
|
+
return 'getAuthToken must return the Bridge access token (a JWT) — not an ID token, an API key or another string';
|
|
1521
|
+
default:
|
|
1522
|
+
return `see the docs entry below for what '${reason}' means for this session`;
|
|
1523
|
+
}
|
|
797
1524
|
}
|
|
798
1525
|
function parseMessage(raw) {
|
|
799
1526
|
if (typeof raw !== 'string')
|
|
@@ -825,14 +1552,18 @@ function parseMessage(raw) {
|
|
|
825
1552
|
* months. Anything is better than nothing here, so fall back to the raw frame.
|
|
826
1553
|
*/
|
|
827
1554
|
function describeAppSyncError(frame) {
|
|
828
|
-
const {
|
|
829
|
-
|
|
1555
|
+
const { message } = frame;
|
|
1556
|
+
const errors = appSyncErrors(frame);
|
|
1557
|
+
if (errors.length > 0) {
|
|
830
1558
|
const parts = errors
|
|
831
1559
|
.map((e) => {
|
|
832
1560
|
if (typeof e === 'string')
|
|
833
1561
|
return e;
|
|
834
1562
|
const m = e?.message;
|
|
835
|
-
|
|
1563
|
+
if (typeof m === 'string')
|
|
1564
|
+
return m;
|
|
1565
|
+
const t = e?.errorType;
|
|
1566
|
+
return typeof t === 'string' ? t : undefined;
|
|
836
1567
|
})
|
|
837
1568
|
.filter((m) => !!m);
|
|
838
1569
|
if (parts.length > 0)
|
|
@@ -847,6 +1578,73 @@ function describeAppSyncError(frame) {
|
|
|
847
1578
|
return 'no error detail supplied by the server';
|
|
848
1579
|
}
|
|
849
1580
|
}
|
|
1581
|
+
/** AppSync puts `errors` at the top level or under `payload` — accept both. */
|
|
1582
|
+
function appSyncErrors(frame) {
|
|
1583
|
+
if (Array.isArray(frame.errors))
|
|
1584
|
+
return frame.errors;
|
|
1585
|
+
const nested = frame.payload?.errors;
|
|
1586
|
+
return Array.isArray(nested) ? nested : [];
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* TBP-643 — is this error frame an auth refusal? AppSync Events does NOT relay
|
|
1590
|
+
* the authorizer's reason and often sends no message text at all, so match on
|
|
1591
|
+
* errorType / errorCode only, never on wording.
|
|
1592
|
+
*/
|
|
1593
|
+
function isAppSyncAuthRefusal(frame) {
|
|
1594
|
+
return appSyncErrors(frame).some((e) => {
|
|
1595
|
+
if (!e || typeof e !== 'object')
|
|
1596
|
+
return false;
|
|
1597
|
+
const { errorType, errorCode } = e;
|
|
1598
|
+
if (typeof errorType === 'string' && /^unauthori[sz]ed/i.test(errorType))
|
|
1599
|
+
return true;
|
|
1600
|
+
const code = typeof errorCode === 'string' ? Number(errorCode) : errorCode;
|
|
1601
|
+
return code === 401 || code === 403;
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
/** Decode a JWT payload WITHOUT verifying it. undefined = not a JWT. */
|
|
1605
|
+
function decodeJwtPayload(token) {
|
|
1606
|
+
const parts = token.split('.');
|
|
1607
|
+
if (parts.length !== 3 || !parts[1])
|
|
1608
|
+
return undefined;
|
|
1609
|
+
try {
|
|
1610
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
1611
|
+
const padded = b64 + '==='.slice((b64.length + 3) % 4);
|
|
1612
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1613
|
+
const g = globalThis;
|
|
1614
|
+
const json = typeof g.atob === 'function'
|
|
1615
|
+
? decodeURIComponent(escape(g.atob(padded)))
|
|
1616
|
+
: g.Buffer.from(padded, 'base64').toString('utf-8');
|
|
1617
|
+
const claims = JSON.parse(json);
|
|
1618
|
+
return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : undefined;
|
|
1619
|
+
}
|
|
1620
|
+
catch {
|
|
1621
|
+
return undefined;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
function hostOf(url) {
|
|
1625
|
+
try {
|
|
1626
|
+
return new URL(url).host;
|
|
1627
|
+
}
|
|
1628
|
+
catch {
|
|
1629
|
+
return url;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
function noop() {
|
|
1633
|
+
// intentionally empty
|
|
1634
|
+
}
|
|
1635
|
+
function errorText(err) {
|
|
1636
|
+
return err instanceof Error ? err.message : String(err);
|
|
1637
|
+
}
|
|
1638
|
+
/** Short per-episode correlation id — quoted in logs, sent to Bridge on diagnose. */
|
|
1639
|
+
function newRef() {
|
|
1640
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1641
|
+
const g = globalThis;
|
|
1642
|
+
if (typeof g.crypto?.getRandomValues === 'function') {
|
|
1643
|
+
const bytes = g.crypto.getRandomValues(new Uint8Array(4));
|
|
1644
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1645
|
+
}
|
|
1646
|
+
return Math.random().toString(16).slice(2, 10).padEnd(8, '0');
|
|
1647
|
+
}
|
|
850
1648
|
/** Subprotocol identifier for AppSync Events realtime channels. */
|
|
851
1649
|
const APPSYNC_WS_PROTOCOL = 'aws-appsync-event-ws';
|
|
852
1650
|
/**
|
|
@@ -888,12 +1686,20 @@ function normalizeAppSyncEndpoint(endpoint) {
|
|
|
888
1686
|
}
|
|
889
1687
|
/**
|
|
890
1688
|
* Build the AppSync Events auth header carried in the `header-…` subprotocol
|
|
891
|
-
* token
|
|
892
|
-
*
|
|
1689
|
+
* token — and in every subscribe frame's `authorization`. This is the ONLY
|
|
1690
|
+
* place the anonymous value is decided.
|
|
1691
|
+
*
|
|
1692
|
+
* Anonymous sessions send `Authorization: REALTIME_ANONYMOUS_TOKEN`, never ''.
|
|
1693
|
+
* TBP-643 — AppSync rejects an empty Authorization itself, BEFORE the Lambda
|
|
1694
|
+
* authorizer runs (`connection_error` / UnauthorizedException 401, no
|
|
1695
|
+
* message), so with '' the authorizer's anonymous-CONNECT branch was
|
|
1696
|
+
* unreachable and no signed-out session could ever connect. The authorizer
|
|
1697
|
+
* treats exactly this marker as "no token" (app channels origin-checked,
|
|
1698
|
+
* everything else denied `no_token`).
|
|
893
1699
|
*/
|
|
894
1700
|
function buildAppSyncAuthHeader(token, host) {
|
|
895
1701
|
return {
|
|
896
|
-
Authorization: token ? `Bearer ${token}` :
|
|
1702
|
+
Authorization: token ? `Bearer ${token}` : REALTIME_ANONYMOUS_TOKEN,
|
|
897
1703
|
host,
|
|
898
1704
|
};
|
|
899
1705
|
}
|