@glassly/cloud-client 0.1.0-dev.0 → 0.1.0-dev.100
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/package.json +2 -2
- package/src/client.ts +7 -8
- package/src/errors.ts +17 -0
- package/src/index.ts +1 -1
- package/src/modules/auth/auth.ts +57 -22
- package/src/modules/runtime/connection.ts +8 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glassly/cloud-client",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.100",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "bun test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@glassly/cloud-protocol": "
|
|
15
|
+
"@glassly/cloud-protocol": "0.1.0-dev.100",
|
|
16
16
|
"tweetnacl": "^1.0.3"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -22,9 +22,8 @@ import { CloudClientError } from "./errors";
|
|
|
22
22
|
import type { ConnectionInit } from "@glassly/cloud-protocol";
|
|
23
23
|
import { systemTimers } from "./timers";
|
|
24
24
|
|
|
25
|
-
// The module implementations
|
|
26
|
-
//
|
|
27
|
-
// design.md exactly.
|
|
25
|
+
// The module implementations, under ./modules/**. This file only constructs
|
|
26
|
+
// them, matching the constructor signatures fixed in design.md exactly.
|
|
28
27
|
import { Auth } from "./modules/auth/auth";
|
|
29
28
|
import { TokenStore } from "./modules/auth/token-store";
|
|
30
29
|
import { Runtime } from "./modules/runtime/runtime";
|
|
@@ -51,12 +50,12 @@ import { Core } from "./modules/core/core";
|
|
|
51
50
|
const DEFAULT_RECONNECT = { baseMs: 500, maxMs: 5_000, jitter: true };
|
|
52
51
|
|
|
53
52
|
/**
|
|
54
|
-
* The default audio codec the client announces in the handshake
|
|
53
|
+
* The default audio codec the client announces in the handshake when a host
|
|
54
|
+
* does not set `config.audio.codec`.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* after connect is decoded correctly.
|
|
56
|
+
* PCM at 16 kHz needs no frame-size negotiation, so it is the safe default; a
|
|
57
|
+
* host whose device captures LC3 sets `config.audio.codec = "lc3"` (and the
|
|
58
|
+
* required `frameSizeBytes`) explicitly.
|
|
60
59
|
*/
|
|
61
60
|
const DEFAULT_AUDIO_CODEC = "pcm" as const;
|
|
62
61
|
const DEFAULT_AUDIO_SAMPLE_RATE = 16_000;
|
package/src/errors.ts
CHANGED
|
@@ -52,3 +52,20 @@ export class AuthExpiredError extends CloudClientError {
|
|
|
52
52
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A token endpoint DEFINITIVELY rejected the presented credential — the RFC
|
|
58
|
+
* `invalid_grant` shape or an authorization-level 401. Only this proves a
|
|
59
|
+
* stored token is dead. Network failures, 5xx, 429, and other 400 shapes are
|
|
60
|
+
* transient: throwing anything else must never cost the stored session
|
|
61
|
+
* (clearing tokens on a flaky cold boot is how users get logged out by an
|
|
62
|
+
* app update). Subclasses `AuthExpiredError` so hosts catching the broad
|
|
63
|
+
* "re-auth required" case keep working.
|
|
64
|
+
*/
|
|
65
|
+
export class AuthRejectedError extends AuthExpiredError {
|
|
66
|
+
constructor(message = "Credential rejected by the token endpoint; re-auth required") {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "AuthRejectedError";
|
|
69
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type {
|
|
|
35
35
|
export type { CloudClientTimers } from "./timers";
|
|
36
36
|
|
|
37
37
|
// Local error types a host can branch on with `instanceof`.
|
|
38
|
-
export { CloudClientError, HttpError, AuthExpiredError } from "./errors";
|
|
38
|
+
export { CloudClientError, HttpError, AuthExpiredError, AuthRejectedError } from "./errors";
|
|
39
39
|
|
|
40
40
|
// The logging hook a host can implement to route library logs.
|
|
41
41
|
export { noopLogger } from "./logger";
|
package/src/modules/auth/auth.ts
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
* - Exchange, refresh, and each per-miniapp mint are single-flighted, so a
|
|
15
15
|
* reconnect storm cannot fire a burst of competing requests (and a rotated
|
|
16
16
|
* refresh token cannot be invalidated out from under a concurrent caller).
|
|
17
|
-
* - If a refresh
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* - If a refresh is DEFINITIVELY rejected (`invalid_grant` / 401) and the
|
|
18
|
+
* host can fetch a fresh subject token on demand, we clear the dead
|
|
19
|
+
* refresh token and exchange once. If no fresh subject is available (or
|
|
20
|
+
* the exchange is itself rejected), `onExpired` fires once and the host
|
|
20
21
|
* re-authenticates. We do not retry forever against a dead refresh token.
|
|
22
|
+
* Transient failures — network errors, 5xx, 429 — never clear stored
|
|
23
|
+
* tokens and never fire `onExpired`: a flaky cold boot (first launch
|
|
24
|
+
* after an app update) must not cost a healthy session.
|
|
21
25
|
*
|
|
22
26
|
* Security: the access token is never written to storage, never given to a
|
|
23
27
|
* miniapp, and no token is ever logged.
|
|
@@ -34,7 +38,7 @@ import type {
|
|
|
34
38
|
import type { HttpClient } from "../../http";
|
|
35
39
|
import type { Logger } from "../../logger";
|
|
36
40
|
import type { HttpTransport } from "../../transports";
|
|
37
|
-
import { AuthExpiredError } from "../../errors";
|
|
41
|
+
import { AuthExpiredError, AuthRejectedError, HttpError } from "../../errors";
|
|
38
42
|
import { decodeClaims } from "./jwt";
|
|
39
43
|
import { TokenStore } from "./token-store";
|
|
40
44
|
|
|
@@ -329,12 +333,23 @@ export class Auth implements AuthModule {
|
|
|
329
333
|
try {
|
|
330
334
|
return await this.refresh(refreshToken, { deferExpired: this.canExchangeFreshSubject() });
|
|
331
335
|
} catch (err) {
|
|
332
|
-
|
|
333
|
-
|
|
336
|
+
// Only a DEFINITIVE rejection warrants burning the session and
|
|
337
|
+
// minting a new one through the subject-token fallback. A transient
|
|
338
|
+
// failure (network down at cold boot, core mid-deploy) keeps the
|
|
339
|
+
// stored refresh token; the caller's next attempt retries with it.
|
|
340
|
+
if (err instanceof AuthExpiredError && this.canExchangeFreshSubject()) {
|
|
341
|
+
this.logger.info("refresh rejected; exchanging fresh subject token");
|
|
334
342
|
try {
|
|
335
343
|
return await this.exchange();
|
|
336
|
-
} catch {
|
|
337
|
-
|
|
344
|
+
} catch (exchangeErr) {
|
|
345
|
+
// The exchange itself was rejected → credentials are truly dead.
|
|
346
|
+
// A transient exchange failure (getSubjectToken offline, POST
|
|
347
|
+
// failed) must NOT declare the session expired — the refresh
|
|
348
|
+
// token was already cleared, but the host's account session is
|
|
349
|
+
// intact and the next attempt re-exchanges.
|
|
350
|
+
if (exchangeErr instanceof AuthRejectedError) {
|
|
351
|
+
this.fireExpired();
|
|
352
|
+
}
|
|
338
353
|
}
|
|
339
354
|
}
|
|
340
355
|
throw err;
|
|
@@ -389,11 +404,16 @@ export class Auth implements AuthModule {
|
|
|
389
404
|
* Refresh near expiry: trade the stored refresh token for a new access token
|
|
390
405
|
* and a rotated refresh token, saving both.
|
|
391
406
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* on-demand subject-token configs) or fires `onExpired` once and
|
|
395
|
-
* `AuthExpiredError`. We do not retry
|
|
396
|
-
* will not heal on its own, and retrying would loop.
|
|
407
|
+
* Only a DEFINITIVE rejection (`invalid_grant` / 401) clears stored state:
|
|
408
|
+
* the caller then either falls back to one fresh subject-token exchange
|
|
409
|
+
* (for on-demand subject-token configs) or fires `onExpired` once and
|
|
410
|
+
* surfaces an `AuthExpiredError`. We do not retry a rejected refresh: a
|
|
411
|
+
* dead refresh token will not heal on its own, and retrying would loop.
|
|
412
|
+
*
|
|
413
|
+
* A transient failure — network error, 5xx during a deploy, 429 — keeps
|
|
414
|
+
* the stored token and propagates as-is: clearing on a transient failure
|
|
415
|
+
* would log out a perfectly healthy session on a flaky cold start (e.g.
|
|
416
|
+
* first launch after an app update).
|
|
397
417
|
*/
|
|
398
418
|
private async refresh(refreshToken: string, opts?: { deferExpired?: boolean }): Promise<string> {
|
|
399
419
|
const body = new URLSearchParams({
|
|
@@ -404,14 +424,17 @@ export class Auth implements AuthModule {
|
|
|
404
424
|
let tokens: TokenResponse;
|
|
405
425
|
try {
|
|
406
426
|
tokens = await this.postForm(REFRESH_PATH, body, "refresh");
|
|
407
|
-
} catch {
|
|
408
|
-
|
|
409
|
-
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (!(err instanceof AuthRejectedError)) {
|
|
429
|
+
throw err; // transient: the stored token may well still be good
|
|
430
|
+
}
|
|
431
|
+
// The refresh token is definitively unusable: drop it so we do not keep
|
|
432
|
+
// presenting a known-bad token.
|
|
410
433
|
await this.store.clear();
|
|
411
434
|
if (!opts?.deferExpired) {
|
|
412
435
|
this.fireExpired();
|
|
413
436
|
}
|
|
414
|
-
throw new AuthExpiredError("token refresh
|
|
437
|
+
throw new AuthExpiredError("token refresh rejected; re-auth required");
|
|
415
438
|
}
|
|
416
439
|
|
|
417
440
|
await this.store.save({
|
|
@@ -504,11 +527,23 @@ export class Auth implements AuthModule {
|
|
|
504
527
|
});
|
|
505
528
|
|
|
506
529
|
if (!res.ok) {
|
|
507
|
-
// The body may carry an RFC `{ error, error_description }
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
530
|
+
// The body may carry an RFC `{ error, error_description }`; read only the
|
|
531
|
+
// machine code so no token field can leak into a message a host might
|
|
532
|
+
// surface. Only `invalid_grant` (or an authorization-level 401) proves
|
|
533
|
+
// the presented credential is dead — anything else (5xx during a deploy,
|
|
534
|
+
// 429, a malformed-request 400) is transient and must not cost the
|
|
535
|
+
// stored session.
|
|
536
|
+
let code: string | undefined;
|
|
537
|
+
try {
|
|
538
|
+
code = ((await res.json()) as { error?: string })?.error;
|
|
539
|
+
} catch {
|
|
540
|
+
// Non-JSON body (proxy error page); status alone decides.
|
|
541
|
+
}
|
|
542
|
+
this.logger.warn("auth token request failed", { label, status: res.status, code });
|
|
543
|
+
if (res.status === 401 || (res.status === 400 && code === "invalid_grant")) {
|
|
544
|
+
throw new AuthRejectedError(`${label} rejected with status ${res.status}`);
|
|
545
|
+
}
|
|
546
|
+
throw new HttpError(`${label} request failed with status ${res.status}`, res.status, code);
|
|
512
547
|
}
|
|
513
548
|
|
|
514
549
|
return (await res.json()) as TokenResponse;
|
|
@@ -12,30 +12,14 @@
|
|
|
12
12
|
* It never imports a real socket: the platform supplies a `WebSocketLike`
|
|
13
13
|
* factory, so the same code runs on the phone and in a Node/Bun test harness.
|
|
14
14
|
*
|
|
15
|
-
* Reconnect
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* nothing schedules the next retry, so the client sits SILENTLY disconnected
|
|
24
|
-
* forever until a full app relaunch. We hit this in dev (ADB/Metro flapping
|
|
25
|
-
* wedged the v2 socket; only a cold relaunch recovered it).
|
|
26
|
-
*
|
|
27
|
-
* Three changes close that gap, belt-and-suspenders:
|
|
28
|
-
* 0. A failed initial `open()` now also enters the reconnect loop. Before
|
|
29
|
-
* this, reconnect was robust only AFTER the first successful session had
|
|
30
|
-
* dropped; if the app booted while cloud was down, `open()` rejected and no
|
|
31
|
-
* retry was queued.
|
|
32
|
-
* 1. The scheduled attempt's `.catch()` now reschedules itself, so a failure
|
|
33
|
-
* that did NOT fire `onClose` still queues the next try. `scheduleReconnect`
|
|
34
|
-
* is idempotent (guarded by `reconnectTimer`), so the double call from
|
|
35
|
-
* `onClose` + this catch never stacks two timers.
|
|
36
|
-
* 2. A lightweight watchdog interval revives the loop if some unforeseen path
|
|
37
|
-
* ever leaves us `closed`, not host-closed, with no reconnect pending. Even
|
|
38
|
-
* if reasoning (1) misses a case, the watchdog guarantees we never sit dead.
|
|
15
|
+
* Reconnect must not depend on `onClose`: a connect attempt can fail without
|
|
16
|
+
* ever firing a clean close (network/DNS blip mid-handshake, or a
|
|
17
|
+
* `WebSocketLike` transport that emits only `onError`), which would otherwise
|
|
18
|
+
* leave the client silently disconnected until relaunch. So the loop is driven
|
|
19
|
+
* from three places: a failed initial `open()`, the scheduled attempt's own
|
|
20
|
+
* `.catch()`, and a watchdog interval that revives it if we are ever `closed`,
|
|
21
|
+
* not host-closed, with no reconnect pending. `scheduleReconnect` is idempotent
|
|
22
|
+
* (guarded by `reconnectTimer`) so the overlapping callers never stack timers.
|
|
39
23
|
*
|
|
40
24
|
* See docs/issues/004-cloud-client/design.md ("src/modules/runtime/connection.ts")
|
|
41
25
|
* and docs/issues/002-cloud-runtime/protocol.md (envelope, handshake, control).
|