portal-mojo 0.2.14 → 0.2.15

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/docs/auth.md CHANGED
@@ -25,8 +25,8 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
25
25
 
26
26
  ## Session mechanics
27
27
 
28
- - JWT pair (`access_token`/`refresh_token`); single-flight refresh (3
29
- concurrent 401s → ONE refresh POST); pre-request gate throws a synthetic
28
+ - JWT pair (`access_token`/`refresh_token`); single-flight refresh (concurrent
29
+ gated HTTP requests and realtime reconnects → ONE refresh POST); pre-request gate throws a synthetic
30
30
  `AuthRequiredError` instead of firing doomed requests.
31
31
  - A temporary refresh failure (network, timeout or server unavailable) blocks
32
32
  the protected request and rejects with the original transport error. It
@@ -34,7 +34,15 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
34
34
  open. Only expired/unusable credentials or confirmed refresh 401/403 emit
35
35
  `unauthorized` and become `AuthRequiredError`. `refreshTokens()` still
36
36
  resolves a boolean, and concurrent gated calls share one refresh POST.
37
+ - Refresh POSTs have a 15-second deadline. A timed-out attempt releases the
38
+ shared flight so a later request or reconnect can try again. RealtimeProvider
39
+ uses this same gate before connecting and before sending authentication;
40
+ applications do not need a second refresh timer for WebSockets.
37
41
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
42
+ - An internal session revision distinguishes same-tab renewal from explicit
43
+ replacement/clearing and cross-tab storage changes. Session-bound realtime
44
+ retains this fence across unmount/remount, so a late renewal cannot undo a
45
+ terminal server denial. It is an in-memory lifecycle fence, not authorization.
38
46
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
39
47
  `'logout'`.
40
48
  - Every flow that answers with a login grant adopts it only when it carries
package/docs/realtime.md CHANGED
@@ -9,6 +9,23 @@ consumer permissions own topic subscriptions. Disabling the provider or losing
9
9
  the auth token closes the socket. A permission loss in one consumer must not
10
10
  disable this shared provider.
11
11
 
12
+ The provider binds both owned and injected clients to the shared auth session.
13
+ Before opening a socket and again when challenged, it waits for `ensureValidToken()`;
14
+ HTTP and realtime share one refresh POST. A slow or temporarily failed renewal
15
+ keeps the connection in `connecting`/`authenticating`/`backoff`, not `auth-failed`.
16
+ Consumers should preserve their current workspace during these transient states.
17
+ Confirmed refresh denial still fails closed. Token replacement invalidates old
18
+ handshakes synchronously, including during a pending refresh.
19
+ An in-flight renewal completing after an authentication failure or forced
20
+ disconnect cannot clear that suspension. An intentional replacement session
21
+ can authenticate again; ordinary token refresh cannot.
22
+
23
+ For a non-React session consumer, call `const unbind = client.bindAuthSession()`
24
+ before `client.setEnabled(true)`, and call `unbind()` on teardown. One binding
25
+ owns the client at a time; unbinding closes the connection without aborting a
26
+ renewal shared by HTTP. Standalone `connect(token)` clients keep explicit-token
27
+ behavior and are responsible for their token lifecycle.
28
+
12
29
  ## Wire contract
13
30
 
14
31
  - URL is derived from the configured API origin as exactly `/ws/realtime/`
@@ -55,7 +72,10 @@ A top-level `{type:'error'}` frame is attributed in order:
55
72
 
56
73
  1. **During authentication** — auth failure: the client closes and suspends
57
74
  until the token value changes. Surfaces through the lifecycle status only;
58
- no `error` event is dispatched.
75
+ no `error` event is dispatched. Exception: the exact direct
76
+ `message === 'Authentication timeout'` before credentials were sent on a
77
+ session-bound socket is a transport timeout and enters bounded retry.
78
+ The same error after credentials were sent remains an authentication failure.
59
79
  2. **While a subscribe/unsubscribe is pending** — that operation's denial
60
80
  (acknowledgements are uncorrelated, so denial is a generic `error`); the
61
81
  topic reports `denied`. Surfaces through the topic status only; no `error`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -61,13 +61,17 @@ export function sessionIsPersistent(): boolean {
61
61
  }
62
62
 
63
63
  export function setTokens(access: string, refresh?: string | null, persistent = true): void {
64
+ storeTokens(access, refresh, persistent, 'session');
65
+ }
66
+
67
+ function storeTokens(access: string, refresh: string | null | undefined, persistent: boolean, source: 'session' | 'refresh'): void {
64
68
  // Clear both storages first — a leftover token in the other storage must
65
69
  // never shadow this session (web-mojo wrote without clearing).
66
70
  clearTokens({ silent: true });
67
71
  const storage = persistent ? localStorage : sessionStorage;
68
72
  storage.setItem(ACCESS_KEY, access);
69
73
  if (refresh) storage.setItem(REFRESH_KEY, refresh);
70
- notifyAuthChanged();
74
+ notifyAuthChanged(source);
71
75
  }
72
76
 
73
77
  export function clearTokens(opts: { silent?: boolean } = {}): void {
@@ -89,8 +93,14 @@ export interface AuthSnapshot {
89
93
 
90
94
  let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
91
95
  let snapshotKey = '∅';
96
+ let sessionRevision = 0;
92
97
  const authListeners = new Set<() => void>();
93
98
 
99
+ /** @internal Same-tab replacement fence; ordinary renewal does not advance it. */
100
+ export function getAuthSessionRevision(): number {
101
+ return sessionRevision;
102
+ }
103
+
94
104
  function computeSnapshot(): void {
95
105
  const access = getAccessToken();
96
106
  const refresh = getRefreshToken();
@@ -106,7 +116,8 @@ function computeSnapshot(): void {
106
116
  };
107
117
  }
108
118
 
109
- function notifyAuthChanged(): void {
119
+ function notifyAuthChanged(source: 'session' | 'refresh' = 'session'): void {
120
+ if (source === 'session') sessionRevision += 1;
110
121
  computeSnapshot();
111
122
  for (const cb of authListeners) cb();
112
123
  }
@@ -212,10 +223,12 @@ async function doRefresh(): Promise<RefreshOutcome> {
212
223
  const superseded = (): boolean => getRefreshToken() !== refresh;
213
224
  const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
214
225
  try {
215
- const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
226
+ const body = await mojoCall(REFRESH_PATH, {
227
+ method: 'POST', body: { refresh_token: refresh }, signal: AbortSignal.timeout(15_000),
228
+ });
216
229
  if (superseded()) return { ok: sessionUsable() };
217
230
  const pkg = body.data as { access_token: string; refresh_token?: string };
218
- setTokens(pkg.access_token, pkg.refresh_token, persistent);
231
+ storeTokens(pkg.access_token, pkg.refresh_token, persistent, 'refresh');
219
232
  emitAuth('refreshed');
220
233
  return { ok: true };
221
234
  } catch (error) {
@@ -3,7 +3,8 @@ import {
3
3
  useSyncExternalStore, type PropsWithChildren,
4
4
  } from 'react';
5
5
  import { apiOrigin } from './client';
6
- import { getAccessToken, subscribeAuth } from './auth';
6
+ import { ensureValidToken, getAccessToken, getAuthSessionRevision, getRefreshToken, subscribeAuth } from './auth';
7
+ import { AuthRequiredError } from './errors';
7
8
 
8
9
  export type RealtimeLifecycleStatus =
9
10
  | 'disabled' | 'idle' | 'connecting' | 'authenticating' | 'ready'
@@ -106,6 +107,10 @@ export class RealtimeClient {
106
107
  private readonly topicTimeoutMs: number;
107
108
  private socket: RealtimeSocket | null = null;
108
109
  private token: string | null = null;
110
+ private authBinding: { unsubscribe: () => void } | null = null;
111
+ private authSessionRevision: number | null = null;
112
+ private preparing: object | null = null;
113
+ private authSent: RealtimeSocket | null = null;
109
114
  private enabled = false;
110
115
  private generation = 0;
111
116
  private reconnectAttempt = 0;
@@ -142,11 +147,44 @@ export class RealtimeClient {
142
147
  return () => this.statusListeners.delete(listener);
143
148
  };
144
149
 
150
+ /** Bind one session owner; renewal shares HTTP's single-flight auth gate. */
151
+ bindAuthSession(): () => void {
152
+ if (this.authBinding) throw new Error('Realtime client already has an auth session owner');
153
+ const binding = { unsubscribe: () => {} };
154
+ this.authBinding = binding;
155
+ this.stop(this.suspended ?? (this.enabled ? 'idle' : 'disabled'));
156
+ // Synchronous invalidation is essential: a React effect can run after
157
+ // an old refresh/handshake has completed for the previous account.
158
+ const syncSession = () => {
159
+ const revision = getAuthSessionRevision();
160
+ const replaced = this.authSessionRevision !== revision;
161
+ this.authSessionRevision = revision;
162
+ // A renewal already in flight when the server denied/revoked this
163
+ // connection is not an intentional replacement session. The fence
164
+ // also survives a renewal completed while no binding was mounted.
165
+ if (!replaced && this.suspended) {
166
+ this.token = getAccessToken();
167
+ return;
168
+ }
169
+ this.setToken(getAccessToken());
170
+ };
171
+ binding.unsubscribe = subscribeAuth(syncSession);
172
+ syncSession();
173
+ if (this.enabled) this.open();
174
+ return () => {
175
+ if (this.authBinding !== binding) return;
176
+ this.disconnect();
177
+ binding.unsubscribe();
178
+ this.authBinding = null;
179
+ };
180
+ }
181
+
145
182
  setEnabled(enabled: boolean): void {
146
183
  if (this.enabled === enabled) return;
147
184
  this.enabled = enabled;
148
185
  if (!enabled) this.stop('disabled');
149
- else if (this.token) this.open();
186
+ else if (this.suspended) this.setLifecycle(this.suspended);
187
+ else if (this.hasCredentials()) this.open();
150
188
  else this.setLifecycle('idle');
151
189
  }
152
190
 
@@ -156,7 +194,7 @@ export class RealtimeClient {
156
194
  this.token = token;
157
195
  this.suspended = null;
158
196
  this.stop(this.enabled && token ? 'idle' : this.enabled ? 'idle' : 'disabled');
159
- if (this.enabled && token) this.open();
197
+ if (this.enabled && this.hasCredentials()) this.open();
160
198
  }
161
199
 
162
200
  connect(token: string): void {
@@ -171,6 +209,8 @@ export class RealtimeClient {
171
209
 
172
210
  destroy(): void {
173
211
  this.disconnect();
212
+ this.authBinding?.unsubscribe();
213
+ this.authBinding = null;
174
214
  this.statusListeners.clear();
175
215
  this.topicListeners.clear();
176
216
  this.eventListeners.clear();
@@ -232,14 +272,18 @@ export class RealtimeClient {
232
272
  }
233
273
 
234
274
  nudge(): void {
235
- if (!this.enabled || !this.token || this.suspended || this.socket || this.retryTimer == null) return;
275
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.retryTimer == null) return;
236
276
  if (this.clock.now() < this.extendedBackoffUntil) return;
237
277
  this.clearRetry();
238
278
  this.open();
239
279
  }
240
280
 
281
+ private hasCredentials(): boolean {
282
+ return Boolean(this.token || (this.authBinding && getRefreshToken()));
283
+ }
284
+
241
285
  private open(): void {
242
- if (!this.enabled || !this.token || this.suspended || this.socket) return;
286
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.preparing) return;
243
287
  const run = this.generation;
244
288
  // Token refresh and provider remount use this same entry point; neither
245
289
  // may bypass a server admission deadline.
@@ -248,6 +292,47 @@ export class RealtimeClient {
248
292
  return;
249
293
  }
250
294
  this.setLifecycle('connecting');
295
+ this.prepareSession(run, null, () => this.openSocket(run));
296
+ }
297
+
298
+ private prepareSession(run: number, socket: RealtimeSocket | null, ready: () => void): void {
299
+ const binding = this.authBinding;
300
+ if (!binding) { ready(); return; }
301
+ if (this.preparing) return;
302
+ const attempt = {};
303
+ this.preparing = attempt;
304
+ const current = () => this.preparing === attempt && this.authBinding === binding
305
+ && this.enabled && !this.suspended && run === this.generation && this.socket === socket;
306
+ void Promise.resolve().then(() => {
307
+ if (current()) return ensureValidToken();
308
+ }).then(() => {
309
+ if (!current()) return;
310
+ this.preparing = null;
311
+ ready();
312
+ }).catch((error: unknown) => {
313
+ if (!current()) return;
314
+ this.preparing = null;
315
+ if (error instanceof AuthRequiredError) this.suspend('auth-failed', 4003, 'Authentication failed');
316
+ else this.retryAuthentication(run, socket);
317
+ });
318
+ }
319
+
320
+ private retryAuthentication(run: number, socket: RealtimeSocket | null): void {
321
+ this.preparing = null;
322
+ this.authSent = null;
323
+ this.socket = null;
324
+ this.clearHeartbeat();
325
+ this.resetTopicConnectionState();
326
+ this.scheduleReconnect(run, 0);
327
+ socket?.close(1000, 'Session renewal pending');
328
+ }
329
+
330
+ private openSocket(run: number): void {
331
+ // A renewal may have completed while an admission cooldown was set.
332
+ if (this.clock.now() < this.extendedBackoffUntil) {
333
+ this.scheduleReconnect(run, 0);
334
+ return;
335
+ }
251
336
  let socket: RealtimeSocket;
252
337
  try {
253
338
  socket = this.socketFactory(this.url);
@@ -267,6 +352,8 @@ export class RealtimeClient {
267
352
  socket.onerror = () => { /* close is the single retry path */ };
268
353
  socket.onclose = (event) => {
269
354
  if (!this.isCurrent(run, socket)) return;
355
+ this.preparing = null;
356
+ this.authSent = null;
270
357
  this.socket = null;
271
358
  this.clearHeartbeat();
272
359
  this.resetTopicConnectionState();
@@ -283,12 +370,16 @@ export class RealtimeClient {
283
370
  if (!frame || typeof frame.type !== 'string') return;
284
371
 
285
372
  if (frame.type === 'auth_required') {
286
- if (this.lifecycle !== 'authenticating' || !this.token) return;
287
- socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
373
+ if (this.lifecycle !== 'authenticating' || !this.token || this.authSent === socket) return;
374
+ this.prepareSession(run, socket, () => {
375
+ // Latch before send: a transport may answer synchronously.
376
+ this.authSent = socket;
377
+ socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
378
+ });
288
379
  return;
289
380
  }
290
381
  if (frame.type === 'auth_success') {
291
- if (this.lifecycle !== 'authenticating') return;
382
+ if (this.lifecycle !== 'authenticating' || this.authSent !== socket) return;
292
383
  this.reconnectAttempt = 0;
293
384
  this.extendedBackoffUntil = 0;
294
385
  this.automaticUserTopic = (typeof frame.user_type === 'string' && (typeof frame.user_id === 'number' || typeof frame.user_id === 'string'))
@@ -325,6 +416,8 @@ export class RealtimeClient {
325
416
  // Fence queued frames before closing: late auth_success must not
326
417
  // clear the deadline or make this refused socket ready.
327
418
  this.extendedBackoffUntil = this.clock.now() + 60_000;
419
+ this.preparing = null;
420
+ this.authSent = null;
328
421
  this.socket = null;
329
422
  this.clearHeartbeat();
330
423
  this.resetTopicConnectionState();
@@ -335,7 +428,11 @@ export class RealtimeClient {
335
428
  }
336
429
  if (frame.type === 'error') {
337
430
  if (this.lifecycle === 'authenticating') {
338
- this.suspend('auth-failed', 4003, 'Authentication failed');
431
+ // The server's admission timer can expire while renewal is
432
+ // pending. No credential was rejected in this specific case.
433
+ if (frame.message === 'Authentication timeout' && this.authBinding && this.authSent !== socket) {
434
+ this.retryAuthentication(run, socket);
435
+ } else this.suspend('auth-failed', 4003, 'Authentication failed');
339
436
  } else if (this.topicOperation) {
340
437
  this.denyCurrentTopic();
341
438
  } else {
@@ -492,6 +589,9 @@ export class RealtimeClient {
492
589
 
493
590
  private suspend(reason: 'auth-failed' | 'forced-disconnect', code: number, message: string): void {
494
591
  this.suspended = reason;
592
+ this.preparing = null;
593
+ this.authSent = null;
594
+ this.clearRetry();
495
595
  this.setLifecycle(reason);
496
596
  const socket = this.socket;
497
597
  this.socket = null;
@@ -502,6 +602,8 @@ export class RealtimeClient {
502
602
 
503
603
  private stop(status: RealtimeLifecycleStatus): void {
504
604
  this.generation += 1;
605
+ this.preparing = null;
606
+ this.authSent = null;
505
607
  this.clearRetry();
506
608
  this.clearHeartbeat();
507
609
  this.resetTopicConnectionState();
@@ -561,8 +663,7 @@ export type RealtimeProviderProps = PropsWithChildren<{
561
663
 
562
664
  export function RealtimeProvider({ children, enabled = true, client, socketFactory, origin }: RealtimeProviderProps) {
563
665
  const owned = useMemo(() => client ?? new RealtimeClient({ socketFactory, origin }), [client, socketFactory, origin]);
564
- const token = useSyncExternalStore(subscribeAuth, getAccessToken, getAccessToken);
565
- useEffect(() => { owned.setToken(token); }, [owned, token]);
666
+ useEffect(() => owned.bindAuthSession(), [owned]);
566
667
  useEffect(() => {
567
668
  owned.setEnabled(enabled);
568
669
  return () => owned.setEnabled(false);