portal-mojo 0.2.9 → 0.2.11

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
@@ -28,6 +28,12 @@ onAuth('rotated', () => queryClient.invalidateQueries()); // same user, re-issu
28
28
  - JWT pair (`access_token`/`refresh_token`); single-flight refresh (3
29
29
  concurrent 401s → ONE refresh POST); pre-request gate throws a synthetic
30
30
  `AuthRequiredError` instead of firing doomed requests.
31
+ - A temporary refresh failure (network, timeout or server unavailable) blocks
32
+ the protected request and rejects with the original transport error. It
33
+ keeps the session and emits `refresh-failed`, so cached workspaces can stay
34
+ open. Only expired/unusable credentials or confirmed refresh 401/403 emit
35
+ `unauthorized` and become `AuthRequiredError`. `refreshTokens()` still
36
+ resolves a boolean, and concurrent gated calls share one refresh POST.
31
37
  - `useAuthSnapshot()` → `{authenticated, uid, email}` — live, cross-tab.
32
38
  - `logout()` is client-side (django-mojo has no logout endpoint) and emits
33
39
  `'logout'`.
package/docs/realtime.md CHANGED
@@ -17,7 +17,8 @@ disable this shared provider.
17
17
  - An open socket is not ready. The client waits for `auth_required`, then sends
18
18
  `{type:'authenticate',token,prefix:'bearer'}` and becomes ready only after
19
19
  `auth_success`. An auth error closes and suspends the connection until the
20
- token value changes.
20
+ token value changes. The exact direct admission-capacity error described
21
+ below is a transport refusal, not an authentication failure.
21
22
  - Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
22
23
  A server-initiated `{type:'ping'}` (django-mojo 1.32+ sends one every 20
23
24
  seconds and culls a socket silent for 90) is answered at once with
@@ -25,7 +26,11 @@ disable this shared provider.
25
26
  dispatched to consumers. Answering from the message handler keeps the socket
26
27
  alive while a background tab throttles the client's own ping timer.
27
28
  Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
28
- least 60 seconds, and focus/visibility cannot bypass that deadline.
29
+ least 60 seconds. The direct `{type:'error',message:'Too many connections'}`
30
+ admission refusal while authenticating or ready uses the same delay, even
31
+ when followed by a normal server close. Focus/visibility, token replacement,
32
+ and provider remount cannot bypass the deadline on that client. Queued frames
33
+ from the refused socket are ignored; recovery still requires authentication.
29
34
  - Desired topics are refcounted and replay after authentication. Operations are
30
35
  serialized because acknowledgements carry only the topic and denial is a
31
36
  generic uncorrelated `error`. Denial yields topic status `denied`; it does not
@@ -42,6 +47,12 @@ disable this shared provider.
42
47
 
43
48
  A top-level `{type:'error'}` frame is attributed in order:
44
49
 
50
+ 0. **Exact admission capacity refusal** — `message === 'Too many connections'`
51
+ while authenticating or ready: close and enter bounded `backoff`, without
52
+ dispatching an application error or declaring account access revoked.
53
+ Substrings, the `error` field, and wrapped application events do not match.
54
+ Consumers retain their independent HTTP authorization checks.
55
+
45
56
  1. **During authentication** — auth failure: the client closes and suspends
46
57
  until the token value changes. Surfaces through the lifecycle status only;
47
58
  no `error` event is dispatched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
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",
@@ -10,8 +10,8 @@
10
10
  // 10 min, or older than 60 min, with a valid refresh token → refresh)
11
11
  // · pre-request gate: bypass /api/token/refresh exactly (recursion guard)
12
12
  // and requests made with no stored token (pre-login / public); otherwise
13
- // ensureValidToken() — throws AuthRequiredError, which the transport
14
- // turns into a synthetic 401 REJECT without calling fetch
13
+ // ensureValidToken() — blocks fetch on any refresh failure; only a
14
+ // confirmed denial becomes AuthRequiredError (synthetic 401)
15
15
  // · auth-code / magic-token URL handoff: the param is scrubbed from the URL
16
16
  // *synchronously, before any network call*, so a slow exchange cannot
17
17
  // leak the one-time code to third-party scripts reading location.search
@@ -174,13 +174,18 @@ export function checkTokenStatus(): { action: TokenAction; reason: string } {
174
174
  return { action: 'none', reason: 'All tokens valid and not expiring soon' };
175
175
  }
176
176
 
177
- let refreshPromise: Promise<boolean> | null = null;
177
+ type RefreshOutcome = { ok: true } | { ok: false; error?: unknown };
178
+ let refreshPromise: Promise<RefreshOutcome> | null = null;
178
179
 
179
180
  /**
180
181
  * Refresh the access token. Single-flight: concurrent callers share one
181
182
  * in-flight POST. Always resolves to a boolean; never throws.
182
183
  */
183
184
  export function refreshTokens(): Promise<boolean> {
185
+ return refreshOutcome().then(result => result.ok);
186
+ }
187
+
188
+ function refreshOutcome(): Promise<RefreshOutcome> {
184
189
  if (!refreshPromise) {
185
190
  refreshPromise = doRefresh().finally(() => {
186
191
  refreshPromise = null;
@@ -189,13 +194,13 @@ export function refreshTokens(): Promise<boolean> {
189
194
  return refreshPromise;
190
195
  }
191
196
 
192
- async function doRefresh(): Promise<boolean> {
197
+ async function doRefresh(): Promise<RefreshOutcome> {
193
198
  const refresh = getRefreshToken();
194
199
  const info = tokenInfo(refresh);
195
200
  if (!refresh || !info?.isValid || info.isExpired) {
196
201
  emitAuth('unauthorized');
197
202
  stopAutoRefresh();
198
- return false;
203
+ return { ok: false };
199
204
  }
200
205
  const persistent = sessionIsPersistent();
201
206
  // The answer is for the session whose refresh token was sent. When the
@@ -208,30 +213,31 @@ async function doRefresh(): Promise<boolean> {
208
213
  const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
209
214
  try {
210
215
  const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
211
- if (superseded()) return sessionUsable();
216
+ if (superseded()) return { ok: sessionUsable() };
212
217
  const pkg = body.data as { access_token: string; refresh_token?: string };
213
218
  setTokens(pkg.access_token, pkg.refresh_token, persistent);
214
219
  emitAuth('refreshed');
215
- return true;
220
+ return { ok: true };
216
221
  } catch (error) {
217
- if (superseded()) return sessionUsable();
222
+ if (superseded()) return { ok: sessionUsable() };
218
223
  const status = error instanceof Error && 'status' in error ? (error as { status: number }).status : 0;
219
224
  if (status === 401 || status === 403) {
220
225
  emitAuth('unauthorized');
221
226
  stopAutoRefresh();
227
+ return { ok: false };
222
228
  } else {
223
229
  // Transient (network/5xx): keep the session, report, let the
224
230
  // watcher or the next gated request retry.
225
231
  emitAuth('refresh-failed', error);
232
+ return { ok: false, error };
226
233
  }
227
- return false;
228
234
  }
229
235
  }
230
236
 
231
237
  /**
232
238
  * Pre-request gate (TokenManager.ensureValidToken). Throws AuthRequiredError
233
- * when the request must not be sent; the transport short-circuits it to a
234
- * synthetic-401 reject without touching the network.
239
+ * on confirmed denial. A temporary refresh failure preserves its original
240
+ * error; both failures block the protected request without touching fetch.
235
241
  */
236
242
  export async function ensureValidToken(): Promise<void> {
237
243
  const status = checkTokenStatus();
@@ -241,8 +247,11 @@ export async function ensureValidToken(): Promise<void> {
241
247
  throw new AuthRequiredError('Both access and refresh tokens are invalid');
242
248
  }
243
249
  if (status.action === 'refresh') {
244
- const ok = await refreshTokens();
245
- if (!ok) throw new AuthRequiredError('Token refresh failed');
250
+ const result = await refreshOutcome();
251
+ if (!result.ok) {
252
+ if ('error' in result) throw result.error;
253
+ throw new AuthRequiredError('Token refresh failed');
254
+ }
246
255
  }
247
256
  }
248
257
 
@@ -241,6 +241,12 @@ export class RealtimeClient {
241
241
  private open(): void {
242
242
  if (!this.enabled || !this.token || this.suspended || this.socket) return;
243
243
  const run = this.generation;
244
+ // Token refresh and provider remount use this same entry point; neither
245
+ // may bypass a server admission deadline.
246
+ if (this.clock.now() < this.extendedBackoffUntil) {
247
+ this.scheduleReconnect(run, 0);
248
+ return;
249
+ }
244
250
  this.setLifecycle('connecting');
245
251
  let socket: RealtimeSocket;
246
252
  try {
@@ -312,6 +318,21 @@ export class RealtimeClient {
312
318
  if (typeof frame.topic === 'string') this.completeTopic(frame.type, frame.topic);
313
319
  return;
314
320
  }
321
+ if (frame.type === 'error' && frame.message === 'Too many connections'
322
+ && (this.lifecycle === 'authenticating' || this.lifecycle === 'ready')) {
323
+ // django-mojo's admission refusal also arrives after the challenge
324
+ // (or a ready-state capacity check), followed by a normal close.
325
+ // Fence queued frames before closing: late auth_success must not
326
+ // clear the deadline or make this refused socket ready.
327
+ this.extendedBackoffUntil = this.clock.now() + 60_000;
328
+ this.socket = null;
329
+ this.clearHeartbeat();
330
+ this.resetTopicConnectionState();
331
+ this.automaticUserTopic = null;
332
+ this.scheduleReconnect(run, 4429);
333
+ socket.close(4429, 'Connection limit');
334
+ return;
335
+ }
315
336
  if (frame.type === 'error') {
316
337
  if (this.lifecycle === 'authenticating') {
317
338
  this.suspend('auth-failed', 4003, 'Authentication failed');