@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/cloud-client",
3
- "version": "0.1.0-dev.0",
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": "^0.1.0-dev.0",
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. Each is owned by another agent under ./modules/**;
26
- // this file only constructs them, matching the constructor signatures fixed in
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
- * LC3 at 16 kHz matches the glasses' on-device codec, so the cloud transcribes
57
- * the same bytes the device captures. A future config knob can override this; for
58
- * now the handshake announces the device default so audio that starts immediately
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";
@@ -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 fails and the host can fetch a fresh subject token on demand,
18
- * we clear the dead refresh token and exchange once. If no fresh subject is
19
- * available (or exchange also fails), `onExpired` fires once and the host
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
- if (this.canExchangeFreshSubject()) {
333
- this.logger.info("refresh failed; exchanging fresh subject token");
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
- this.fireExpired();
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
- * On failure (the refresh token is dead or revoked) we clear stored state.
393
- * The caller either falls back to one fresh subject-token exchange (for
394
- * on-demand subject-token configs) or fires `onExpired` once and surfaces an
395
- * `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
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
- // The refresh token is unusable: drop it so we do not keep presenting a
409
- // known-bad token.
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 failed; re-auth required");
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 }`, but we keep the
508
- // thrown detail to the status + label so no token field can leak into a
509
- // message a host might surface. The caller maps this to re-auth.
510
- this.logger.warn("auth token request failed", { label, status: res.status });
511
- throw new AuthExpiredError(`${label} request failed with status ${res.status}`);
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 robustness (why the loop is self-sustaining + watchdogged):
16
- * The reconnect loop used to be driven ONLY by the socket's `onClose` event
17
- * (`handleClose` -> `scheduleReconnect`), and a scheduled attempt's
18
- * `connectOnce().catch()` just swallowed the rejection, trusting that a close
19
- * event would always fire and schedule the next try. That assumption is the
20
- * bug: a connect attempt can fail WITHOUT ever firing a clean `onClose` -- a
21
- * transient network/DNS blip mid-handshake, or a `WebSocketLike` transport that
22
- * emits only `onError` and no `onClose`. When that happens the chain breaks:
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).