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 +6 -0
- package/docs/realtime.md +13 -2
- package/package.json +1 -1
- package/src/client/auth.ts +22 -13
- package/src/client/realtime.ts +21 -0
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
|
|
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.
|
|
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",
|
package/src/client/auth.ts
CHANGED
|
@@ -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() —
|
|
14
|
-
//
|
|
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
|
-
|
|
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<
|
|
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
|
-
*
|
|
234
|
-
*
|
|
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
|
|
245
|
-
if (!ok)
|
|
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
|
|
package/src/client/realtime.ts
CHANGED
|
@@ -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');
|