@mentra/cloud-client 3.3.0-dev.906 → 3.3.0-dev.910

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/node/index.ts CHANGED
@@ -50,5 +50,7 @@ export {
50
50
  CloudClientError,
51
51
  HttpError,
52
52
  AuthExpiredError,
53
+ AuthUnavailableError,
54
+ SessionRevocationError,
53
55
  noopLogger,
54
56
  } from "@mentra/cloud-client";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/cloud-client",
3
- "version": "3.3.0-dev.906",
3
+ "version": "3.3.0-dev.910",
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
- "@mentra/cloud-protocol": "3.3.0-dev.906",
15
+ "@mentra/cloud-protocol": "3.3.0-dev.910",
16
16
  "tweetnacl": "^1.0.3"
17
17
  },
18
18
  "devDependencies": {
package/src/errors.ts CHANGED
@@ -25,17 +25,20 @@ export class CloudClientError extends Error {
25
25
  * `status` is the HTTP status so a caller can branch (for example a 401 triggers
26
26
  * one refresh-and-retry). `code` is the optional machine-readable error code the
27
27
  * cloud puts in the JSON body, when present, for finer branching than status
28
- * alone allows.
28
+ * alone allows. `retryAfterSeconds` is the server's `Retry-After` delay in whole
29
+ * seconds, set only when the response carried the delta-seconds form.
29
30
  */
30
31
  export class HttpError extends CloudClientError {
31
32
  status!: number;
32
33
  code?: string;
34
+ retryAfterSeconds?: number;
33
35
 
34
- constructor(message: string, status: number, code?: string) {
36
+ constructor(message: string, status: number, code?: string, retryAfterSeconds?: number) {
35
37
  super(message);
36
38
  this.name = "HttpError";
37
39
  this.status = status;
38
40
  this.code = code;
41
+ this.retryAfterSeconds = retryAfterSeconds;
39
42
  Object.setPrototypeOf(this, new.target.prototype);
40
43
  }
41
44
  }
@@ -70,3 +73,25 @@ export class AuthExpiredError extends CloudClientError {
70
73
  Object.setPrototypeOf(this, new.target.prototype);
71
74
  }
72
75
  }
76
+
77
+ /**
78
+ * Thrown when a credential request could not be completed for a reason that does
79
+ * not show the credentials are dead: the network failed or timed out, Core
80
+ * answered 408, 429 or 5xx, or the answer was not a Core answer at all (a body
81
+ * that is not JSON, an unrecognized 4xx). The stored credentials were kept, so
82
+ * the same call may well succeed later; a host retries instead of re-authenticating,
83
+ * and `onExpired` does not fire. `status` is the HTTP status when something
84
+ * answered, and `cause` is the underlying failure.
85
+ */
86
+ export class AuthUnavailableError extends CloudClientError {
87
+ readonly status?: number;
88
+ readonly cause: unknown;
89
+
90
+ constructor(message: string, options?: { status?: number; cause?: unknown }) {
91
+ super(message);
92
+ this.name = "AuthUnavailableError";
93
+ this.status = options?.status;
94
+ this.cause = options?.cause;
95
+ Object.setPrototypeOf(this, new.target.prototype);
96
+ }
97
+ }
package/src/http.ts CHANGED
@@ -115,6 +115,19 @@ function isAbortError(error: unknown): boolean {
115
115
  return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
116
116
  }
117
117
 
118
+ /**
119
+ * Read a `Retry-After` header in its delta-seconds form (a non-negative whole
120
+ * number of seconds). The HTTP-date form, signs, fractions, exponents and any
121
+ * other text are ignored: a caller that wants a delay gets a number or nothing,
122
+ * never a guess. A value too large to be an exact integer is also ignored.
123
+ */
124
+ function parseRetryAfterSeconds(headers: Headers | undefined): number | undefined {
125
+ const raw = headers?.get("Retry-After")?.trim();
126
+ if (!raw || !/^\d+$/.test(raw)) return undefined;
127
+ const seconds = Number(raw);
128
+ return Number.isSafeInteger(seconds) ? seconds : undefined;
129
+ }
130
+
118
131
  export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
119
132
  const { baseUrl, getToken, logger } = deps;
120
133
  const executeFetch = deps.fetch ?? globalThis.fetch;
@@ -304,7 +317,12 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
304
317
  // No JSON body, or unparseable: fall back to status alone.
305
318
  }
306
319
  throwIfAborted(signal);
307
- return new HttpError(`HTTP ${res.status} on ${method} ${path}${detail}`, res.status, code);
320
+ return new HttpError(
321
+ `HTTP ${res.status} on ${method} ${path}${detail}`,
322
+ res.status,
323
+ code,
324
+ parseRetryAfterSeconds(res.headers),
325
+ );
308
326
  }
309
327
 
310
328
  /**
package/src/index.ts CHANGED
@@ -41,9 +41,14 @@ export {
41
41
  CloudClientError,
42
42
  HttpError,
43
43
  AuthExpiredError,
44
+ AuthUnavailableError,
44
45
  SessionRevocationError,
45
46
  } from "./errors";
46
47
 
48
+ // Reads a Core token's claims (`sub` is the Mentra user ID) without verifying it; the cloud verifies every call.
49
+ export { decodeClaims } from "./modules/auth/jwt";
50
+ export type { JwtClaims } from "./modules/auth/jwt";
51
+
47
52
  // The logging hook a host can implement to route library logs.
48
53
  export { noopLogger } from "./logger";
49
54
  export type { Logger } from "./logger";
@@ -60,6 +65,9 @@ export type {
60
65
  export type {
61
66
  AddReportArtifactsResult,
62
67
  AddReportLogsOptions,
68
+ FleetAck,
69
+ FleetAckResult,
70
+ FleetReportOutcome,
63
71
  ReportAttachmentInput,
64
72
  ReportContext,
65
73
  ReportDetails,
@@ -14,10 +14,23 @@
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
20
- * re-authenticates. We do not retry forever against a dead refresh token.
17
+ * - If Core definitively rejects the refresh token (see
18
+ * `DEAD_REFRESH_TOKEN_ERRORS`) and the host can fetch a fresh subject token
19
+ * on demand, we clear the dead refresh token and exchange once. If no fresh
20
+ * subject is available (or exchange also fails), `onExpired` fires once and
21
+ * the host re-authenticates. We do not retry forever against a dead refresh
22
+ * token.
23
+ * - Any other refresh failure (network error, timeout, 408, 429, 5xx, an answer
24
+ * that is not Core's) says nothing about the token. It is kept, `onExpired`
25
+ * does not fire, no exchange is attempted, and `AuthUnavailableError` is
26
+ * thrown so the caller retries later with the same refresh token.
27
+ * - Exchange failures are classified the same way: only Core's own refusal of
28
+ * the subject token (`EXCHANGE_REFUSAL_ERRORS`) is an `AuthExpiredError`, and
29
+ * `onExpired` fires only when that refusal answers the fresh exchange that
30
+ * follows a rejected refresh token. Anything else, including the host's
31
+ * `getSubjectToken` throwing, is an `AuthUnavailableError` and notifies no one.
32
+ * - A rejection or exchange answer that arrives after `clearSession()` is about
33
+ * a session that is gone: it notifies no one and restores nothing.
21
34
  *
22
35
  * Security: the access token is never written to storage, never given to a
23
36
  * miniapp, and no token is ever logged.
@@ -29,7 +42,7 @@ import type {AuthConfig, CoreAuthConfig, RuntimeAuthConfig, SubjectTokenType} fr
29
42
  import type {HttpClient} from "../../http"
30
43
  import type {Logger} from "../../logger"
31
44
  import type {HttpTransport} from "../../transports"
32
- import {AuthExpiredError, HttpError, SessionRevocationError} from "../../errors"
45
+ import {AuthExpiredError, AuthUnavailableError, HttpError, SessionRevocationError} from "../../errors"
33
46
  import {systemTimers, type CloudClientTimers} from "../../timers"
34
47
  import {decodeClaims} from "./jwt"
35
48
  import {TokenStore} from "./token-store"
@@ -62,7 +75,7 @@ export interface AuthModule {
62
75
  // credentials are always cleared; rejects with `SessionRevocationError` when
63
76
  // Core did not confirm revocation of the server-side session.
64
77
  clearSession(): Promise<void>
65
- // refresh failed; the host must send the user back through login
78
+ // Core rejected the refresh token; the host must send the user back through login
66
79
  onExpired(handler: () => void): () => void
67
80
  }
68
81
 
@@ -142,6 +155,88 @@ function isRetryableRevokeError(err: unknown): boolean {
142
155
  return err instanceof HttpError && (err.status === 0 || err.status >= 500)
143
156
  }
144
157
 
158
+ /**
159
+ * What counts as Core itself refusing a token request, as opposed to a request
160
+ * that merely failed. Core renders every refusal as `{error, error_description}`
161
+ * through its global error translator (`api/app.ts`) with the status its
162
+ * `OauthError` carries (`types/oauth.types.ts`). Only a 400 or 401 whose body has
163
+ * one of the codes below counts; RFC 6749 lets a server pick between the two
164
+ * statuses, so both are accepted for every code. A 5xx, 408 or 429, a body that
165
+ * is not JSON, or any other 4xx is not Core talking about the credentials at all
166
+ * (a proxy, a captive portal, an outage), so it is never a refusal.
167
+ */
168
+ const CORE_REFUSAL_STATUSES: ReadonlySet<number> = new Set([400, 401])
169
+
170
+ /**
171
+ * The only answers to `POST /api/client/auth/refresh` that say the refresh token
172
+ * (or the session it belongs to) is gone for good. `refreshSession`
173
+ * (`services/session.service.ts`) refuses a token for exactly two reasons:
174
+ *
175
+ * - `invalid_grant`, HTTP 400: the token is unknown, expired or already used,
176
+ * its session identity is incomplete, or its account is deleted.
177
+ * - `unauthorized_client`, HTTP 401: the session's tenant (OEM or enterprise
178
+ * org) is unknown or disabled.
179
+ *
180
+ * `invalid_request` and `unsupported_grant_type` describe the shape of the
181
+ * request (a missing `refresh_token` or `grant_type`), which a proxy that drops
182
+ * or rewrites the form body produces just as well, so they are no verdict on the
183
+ * token.
184
+ */
185
+ const DEAD_REFRESH_TOKEN_ERRORS: ReadonlySet<string> = new Set(["invalid_grant", "unauthorized_client"])
186
+
187
+ /**
188
+ * The answers to `POST /api/client/auth/exchange` that say the subject token is
189
+ * unusable (`oem.service.ts`, `session.service.ts`): `invalid_grant` (it failed
190
+ * verification or lacks required claims), `unauthorized_client` (its OEM is
191
+ * unknown or disabled), and `invalid_request` (it is not a parseable JWT).
192
+ * `unsupported_grant_type` is left out for the same reason as on refresh.
193
+ */
194
+ const EXCHANGE_REFUSAL_ERRORS: ReadonlySet<string> = new Set([
195
+ "invalid_grant",
196
+ "unauthorized_client",
197
+ "invalid_request",
198
+ ])
199
+
200
+ /** OAuth error codes are short snake_case words; anything else is not safe to keep or log. */
201
+ const OAUTH_ERROR_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/
202
+
203
+ /**
204
+ * A token endpoint answered with a non-2xx status. `status` is the HTTP status and
205
+ * `code` is the OAuth `error` from a JSON body, when there was a well-formed one.
206
+ *
207
+ * It is an `AuthExpiredError` so the logout refresh, which has always thrown that
208
+ * for any non-2xx, is unchanged. `requestTokens` lets it out only when it is
209
+ * Core's refusal (`isCoreRefusal`); every other non-2xx becomes an
210
+ * `AuthUnavailableError` there.
211
+ */
212
+ class TokenEndpointError extends AuthExpiredError {
213
+ constructor(
214
+ message: string,
215
+ readonly status: number,
216
+ readonly code?: string,
217
+ ) {
218
+ super(message)
219
+ }
220
+ }
221
+
222
+ /** Core definitively rejected the refresh token: it is cleared, and the host must re-authenticate. */
223
+ class RefreshTokenRejectedError extends AuthExpiredError {}
224
+
225
+ /** `err` is Core itself refusing the request: a 400 or 401 carrying one of `codes`. */
226
+ function isCoreRefusal(err: unknown, codes: ReadonlySet<string>): err is TokenEndpointError {
227
+ return (
228
+ err instanceof TokenEndpointError &&
229
+ CORE_REFUSAL_STATUSES.has(err.status) &&
230
+ err.code !== undefined &&
231
+ codes.has(err.code)
232
+ )
233
+ }
234
+
235
+ /** The caller or the transport cancelled the request. */
236
+ function isAbortError(error: unknown): boolean {
237
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
238
+ }
239
+
145
240
  export class Auth implements AuthModule {
146
241
  private readonly http?: HttpClient
147
242
  private readonly store: TokenStore
@@ -434,13 +529,12 @@ export class Auth implements AuthModule {
434
529
  try {
435
530
  return await this.refresh(refreshToken, {deferExpired: this.canExchangeFreshSubject(), generation})
436
531
  } catch (err) {
437
- if (this.canExchangeFreshSubject()) {
438
- this.logger.info("refresh failed; exchanging fresh subject token")
439
- try {
440
- return await this.exchange(generation)
441
- } catch {
442
- this.fireExpired()
443
- }
532
+ // Only a definitive rejection of the refresh token earns a fresh subject
533
+ // exchange. A transient failure would meet the same network, an abort
534
+ // was the caller's answer, and a cleared session must not reacquire
535
+ // credentials from the upstream provider.
536
+ if (err instanceof RefreshTokenRejectedError && this.canExchangeFreshSubject()) {
537
+ return this.exchangeAfterRejection(generation, err)
444
538
  }
445
539
  throw err
446
540
  }
@@ -448,6 +542,36 @@ export class Auth implements AuthModule {
448
542
  return this.exchange(generation)
449
543
  }
450
544
 
545
+ /**
546
+ * One fresh subject exchange after Core rejected the refresh token (the host
547
+ * can supply a subject token on demand). The dead refresh token is already
548
+ * cleared, so a failure here leaves the next `getCoreToken` on the first-use
549
+ * exchange; `onExpired` fires only if Core itself refuses the fresh subject
550
+ * token, which is when the host really must re-authenticate.
551
+ */
552
+ private async exchangeAfterRejection(generation: number, rejection: RefreshTokenRejectedError): Promise<string> {
553
+ this.assertSessionActive(generation)
554
+ this.logger.info("refresh token rejected; exchanging fresh subject token")
555
+ try {
556
+ return await this.exchange(generation)
557
+ } catch (err) {
558
+ if (isAbortError(err)) throw err
559
+ // A logout that raced the exchange wins over whatever the exchange came to.
560
+ this.assertSessionActive(generation)
561
+ if (isCoreRefusal(err, EXCHANGE_REFUSAL_ERRORS)) {
562
+ this.fireExpired()
563
+ throw rejection
564
+ }
565
+ // Already classified: a retryable failure, or a local problem that is not about the credentials.
566
+ if (err instanceof AuthUnavailableError || err instanceof AuthExpiredError) throw err
567
+ // The host's getSubjectToken threw (or the like): nothing was refused.
568
+ this.logger.warn("fresh subject token could not be exchanged; keeping the session retryable", {
569
+ error: err instanceof Error ? err.name : typeof err,
570
+ })
571
+ throw new AuthUnavailableError("fresh subject token could not be exchanged; try again later", {cause: err})
572
+ }
573
+ }
574
+
451
575
  /**
452
576
  * First-use exchange: trade the configured subject token for an access +
453
577
  * refresh pair and save them.
@@ -481,7 +605,7 @@ export class Auth implements AuthModule {
481
605
  subject_token_type: SUBJECT_TOKEN_TYPE_URN[subject.type],
482
606
  })
483
607
 
484
- const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange")
608
+ const tokens = await this.requestTokens(EXCHANGE_PATH, body, "exchange", EXCHANGE_REFUSAL_ERRORS)
485
609
  await this.saveTokenPair(
486
610
  {
487
611
  accessToken: tokens.access_token,
@@ -500,13 +624,20 @@ export class Auth implements AuthModule {
500
624
  * Refresh near expiry: trade the stored refresh token for a new access token
501
625
  * and a rotated refresh token, saving both.
502
626
  *
503
- * On failure (the refresh token is dead or revoked) we clear stored state.
504
- * The caller either falls back to one fresh subject-token exchange (for
505
- * on-demand subject-token configs) or fires `onExpired` once and surfaces an
506
- * `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
507
- * will not heal on its own, and retrying would loop.
627
+ * Only Core's definitive rejection of the refresh token
628
+ * (`DEAD_REFRESH_TOKEN_ERRORS`) clears the stored state. The caller either
629
+ * falls back to one fresh subject-token exchange (for on-demand subject-token
630
+ * configs) or fires `onExpired` once; both surface a
631
+ * `RefreshTokenRejectedError`. We do not retry refresh forever: a dead
632
+ * refresh token will not heal on its own, and retrying would loop.
633
+ *
634
+ * Every other failure leaves the refresh token in place for the next call: it
635
+ * says nothing about the token, and Core accepts a retry with the same token
636
+ * even when it rotated the token and the response never arrived. Those throw
637
+ * `AuthUnavailableError`, except an abort, which propagates unchanged.
508
638
  */
509
639
  private async refresh(refreshToken: string, opts?: {deferExpired?: boolean; generation?: number}): Promise<string> {
640
+ const generation = opts?.generation ?? this.sessionGeneration
510
641
  const body = new URLSearchParams({
511
642
  grant_type: "refresh_token",
512
643
  refresh_token: refreshToken,
@@ -514,15 +645,20 @@ export class Auth implements AuthModule {
514
645
 
515
646
  let tokens: TokenResponse
516
647
  try {
517
- tokens = await this.postForm(REFRESH_PATH, body, "refresh")
518
- } catch {
519
- // The refresh token is unusable: drop it so we do not keep presenting a
520
- // known-bad token.
648
+ tokens = await this.requestTokens(REFRESH_PATH, body, "refresh", DEAD_REFRESH_TOKEN_ERRORS)
649
+ } catch (err) {
650
+ // An abort, a retryable failure and a local problem are not a rejection of the token.
651
+ if (!isCoreRefusal(err, DEAD_REFRESH_TOKEN_ERRORS)) throw err
652
+ // A rejection that arrives after logout is about a session that is already gone: it must
653
+ // not notify the host, clear what a newer session saved, or send the caller to exchange.
654
+ this.assertSessionActive(generation)
655
+ // Core rejected the refresh token for good: drop it so we do not keep
656
+ // presenting a known-bad token.
521
657
  await this.store.clear()
522
658
  if (!opts?.deferExpired) {
523
659
  this.fireExpired()
524
660
  }
525
- throw new AuthExpiredError("token refresh failed; re-auth required")
661
+ throw new RefreshTokenRejectedError("token refresh failed; re-auth required")
526
662
  }
527
663
 
528
664
  await this.saveTokenPair(
@@ -530,7 +666,7 @@ export class Auth implements AuthModule {
530
666
  accessToken: tokens.access_token,
531
667
  refreshToken: tokens.refresh_token,
532
668
  },
533
- opts?.generation ?? this.sessionGeneration,
669
+ generation,
534
670
  )
535
671
  this.expiredFired = false
536
672
  this.logger.debug("refreshed access token")
@@ -605,6 +741,42 @@ export class Auth implements AuthModule {
605
741
  }
606
742
  }
607
743
 
744
+ /**
745
+ * `postForm` for the calls whose failure says something about the credentials
746
+ * (`/exchange` and `/refresh`). Settles as exactly one of:
747
+ * - the tokens;
748
+ * - the caller's abort, unchanged;
749
+ * - a `TokenEndpointError` that is Core's own refusal (`isCoreRefusal` with
750
+ * `refusals`), for the caller to act on;
751
+ * - an `AuthExpiredError` that is not a failed request (no Core endpoint
752
+ * configured), unchanged;
753
+ * - `AuthUnavailableError` for everything else: a network error, a timeout, a
754
+ * 408, 429 or 5xx, a body that is not Core's JSON, any other 4xx. None of
755
+ * those says the credentials are bad, so the caller keeps them and retries.
756
+ */
757
+ private async requestTokens(
758
+ path: string,
759
+ body: URLSearchParams,
760
+ label: string,
761
+ refusals: ReadonlySet<string>,
762
+ ): Promise<TokenResponse> {
763
+ try {
764
+ return await this.postForm(path, body, label)
765
+ } catch (err) {
766
+ if (isAbortError(err) || isCoreRefusal(err, refusals)) throw err
767
+ if (err instanceof AuthExpiredError && !(err instanceof TokenEndpointError)) throw err
768
+ // Core answering with an error we do not recognize still carries its status;
769
+ // a transport or parse failure does not.
770
+ const status = err instanceof TokenEndpointError ? err.status : undefined
771
+ this.logger.warn("token request failed; keeping the credentials", {
772
+ label,
773
+ ...(status !== undefined ? {status} : {}),
774
+ error: err instanceof Error ? err.name : typeof err,
775
+ })
776
+ throw new AuthUnavailableError(`${label} could not be completed; try again later`, {status, cause: err})
777
+ }
778
+ }
779
+
608
780
  /**
609
781
  * POST a form-encoded body to a token endpoint and parse the RFC token
610
782
  * response.
@@ -630,16 +802,30 @@ export class Auth implements AuthModule {
630
802
  if (signal?.aborted) throw new Error("Core session revocation timed out")
631
803
 
632
804
  if (!res.ok) {
633
- // The body may carry an RFC `{ error, error_description }`, but we keep the
634
- // thrown detail to the status + label so no token field can leak into a
635
- // message a host might surface. The caller maps this to re-auth.
636
- this.logger.warn("auth token request failed", {label, status: res.status})
637
- throw new AuthExpiredError(`${label} request failed with status ${res.status}`)
805
+ // The body may carry an RFC `{ error, error_description }`. We keep only
806
+ // the short `error` code (the caller decides what it means) and never the
807
+ // description, so no token field can leak into a message a host might
808
+ // surface.
809
+ // Only a 400 or 401 body is ever consulted, so no other body is read.
810
+ const code = CORE_REFUSAL_STATUSES.has(res.status) ? await this.readOauthErrorCode(res) : undefined
811
+ this.logger.warn("auth token request failed", {label, status: res.status, ...(code ? {code} : {})})
812
+ throw new TokenEndpointError(`${label} request failed with status ${res.status}`, res.status, code)
638
813
  }
639
814
 
640
815
  return (await res.json()) as TokenResponse
641
816
  }
642
817
 
818
+ /** The OAuth `error` code of a refusal, or undefined when the body is not Core's `{error}` JSON. */
819
+ private async readOauthErrorCode(res: Response): Promise<string | undefined> {
820
+ try {
821
+ const data: unknown = await res.json()
822
+ const code = typeof data === "object" && data !== null ? (data as {error?: unknown}).error : undefined
823
+ return typeof code === "string" && OAUTH_ERROR_CODE_PATTERN.test(code) ? code : undefined
824
+ } catch {
825
+ return undefined
826
+ }
827
+ }
828
+
643
829
  /**
644
830
  * Join the core base URL and a path without a double or missing slash.
645
831
  *
@@ -6,8 +6,8 @@
6
6
  * helper, which attaches the access token from `cloud.auth` as the Bearer, so
7
7
  * core never touches credentials itself.
8
8
  *
9
- * Today it exposes the unified report submission surface and the support
10
- * profile update.
9
+ * Today it exposes the unified report submission surface, the support
10
+ * profile update and the Fleet report.
11
11
  *
12
12
  * Guardrail: this is device-facing only. It deliberately carries none of the
13
13
  * Dev Console / OEM Portal / store web UI surface; those are separate clients.
@@ -16,6 +16,8 @@
16
16
  * ("src/modules/core/core.ts").
17
17
  */
18
18
  import type { HttpClient } from "../../http";
19
+ import type { FleetReport } from "@mentra/cloud-protocol";
20
+ import { Fleet, type FleetReportOutcome } from "./fleet";
19
21
  import {
20
22
  Reports,
21
23
  type AddReportArtifactsResult,
@@ -53,6 +55,7 @@ export type {
53
55
  SubmitReportInput,
54
56
  SubmitReportResult,
55
57
  } from "./reports";
58
+ export type { FleetAck, FleetAckResult, FleetReportOutcome } from "./fleet";
56
59
  export type {
57
60
  SupportConnectionState,
58
61
  SupportProfileUpdateResult,
@@ -102,11 +105,20 @@ export class Core {
102
105
  readonly supportProfile: {
103
106
  update(input: SupportStateInput): Promise<SupportProfileUpdateResult>;
104
107
  };
108
+ readonly fleet: {
109
+ /**
110
+ * Rejects only for an authentication failure (`AuthExpiredError`, or an `HttpError` with status 401) or an abort.
111
+ * Every other result, including any network or unexpected failure, is a `FleetReportOutcome`. `opts.bearer` sends
112
+ * that Core token instead of the token source's.
113
+ */
114
+ report(batch: FleetReport, opts?: { signal?: AbortSignal; bearer?: string }): Promise<FleetReportOutcome>;
115
+ };
105
116
 
106
117
  constructor(deps: CoreDeps) {
107
118
  const { http } = deps;
108
119
  const reports = new Reports({ http });
109
120
  const supportProfiles = new SupportProfiles(http);
121
+ const fleet = new Fleet(http);
110
122
 
111
123
  this.reports = {
112
124
  submit: reports.submit.bind(reports),
@@ -120,5 +132,8 @@ export class Core {
120
132
  this.supportProfile = {
121
133
  update: supportProfiles.update.bind(supportProfiles),
122
134
  };
135
+ this.fleet = {
136
+ report: fleet.report.bind(fleet),
137
+ };
123
138
  }
124
139
  }
@@ -0,0 +1,175 @@
1
+ import {FLEET_NOT_INSTALLED, FLEET_SCHEMA_VERSION, type FleetReport} from "@mentra/cloud-protocol"
2
+ import {AuthExpiredError, HttpError} from "../../errors"
3
+ import type {HttpClient} from "../../http"
4
+
5
+ const FLEET_REPORT_PATH = "/api/client/fleet/report"
6
+
7
+ /** Core's whole-response error codes are short snake_case words; anything else is not safe to surface. */
8
+ const ERROR_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/
9
+
10
+ const RESULT_STATUSES = ["accepted", "duplicate", "retryable", "rejected"] as const
11
+
12
+ /**
13
+ * Fleet's answer for one record of the batch, as this client reads it. `index` is the record's position in the batch
14
+ * and `recordId`, when present, names it. A status this client does not know reads as `retryable`, so the record is
15
+ * kept and sent again. `reasonCode` is given only for a rejection, and only when it is a short snake_case code; one
16
+ * this client does not know is kept as given.
17
+ */
18
+ export interface FleetAckResult {
19
+ index: number
20
+ recordId?: string
21
+ status: (typeof RESULT_STATUSES)[number]
22
+ reasonCode?: string
23
+ retryAfterSeconds?: number
24
+ }
25
+
26
+ /** A per-record acknowledgement: one result per record Fleet answered for. */
27
+ export interface FleetAck {
28
+ schemaVersion: typeof FLEET_SCHEMA_VERSION
29
+ results: FleetAckResult[]
30
+ }
31
+
32
+ /**
33
+ * What one `POST /api/client/fleet/report` came to. Every answer Core or Fleet can give is an outcome, so a caller
34
+ * branches on `kind` and never on an exception or a status code.
35
+ */
36
+ export type FleetReportOutcome =
37
+ /** Core answered 200 with a per-record acknowledgement. */
38
+ | {kind: "results"; ack: FleetAck}
39
+ /** Core answered 404 `fleet_not_installed`: Fleet is not installed on this Core. */
40
+ | {kind: "not_installed"}
41
+ /**
42
+ * Something answered 404 without Fleet's own code: a Core from before Fleet, which has no such route, or a proxy in
43
+ * front of it. Not proof that Fleet is not installed, so a caller stops asking for now but keeps what it holds.
44
+ */
45
+ | {kind: "no_fleet_route"}
46
+ /**
47
+ * Fleet or the network is not answering usefully right now: 503, 429, any other server or unexpected failure, a
48
+ * network error or timeout, or a 200 whose body is not an acknowledgement. `retryAfterSeconds` is the server's
49
+ * `Retry-After` in whole seconds, or null when it gave none.
50
+ */
51
+ | {kind: "unavailable"; retryAfterSeconds: number | null}
52
+ /** Core answered 413. */
53
+ | {kind: "batch_too_large"}
54
+ /** Core answered 400. `code` is Core's error code (`invalid_batch`, `invalid_body`, ...), or `unknown`. */
55
+ | {kind: "batch_invalid"; code: string}
56
+ /** Core answered 403. */
57
+ | {kind: "forbidden"}
58
+
59
+ const unavailable = (retryAfterSeconds: number | null = null): FleetReportOutcome => ({
60
+ kind: "unavailable",
61
+ retryAfterSeconds,
62
+ })
63
+
64
+ /** The caller (or the transport) cancelled the request. */
65
+ function isAbortError(error: unknown): boolean {
66
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
67
+ }
68
+
69
+ /** The credentials were refused or are gone: the caller must refresh or sign in again, so this is never an outcome. */
70
+ function isAuthFailure(error: unknown): boolean {
71
+ return error instanceof AuthExpiredError || (error instanceof HttpError && error.status === 401)
72
+ }
73
+
74
+ function outcomeForHttpError(error: HttpError): FleetReportOutcome {
75
+ switch (error.status) {
76
+ // A transport failure (DNS, reset, timeout) after the client's own retries. There was no answer.
77
+ case 0:
78
+ return unavailable()
79
+ case 404:
80
+ // Only Fleet's own answer means "not installed". Any other 404 (an older Core without the route, a proxy) must
81
+ // not read as it, because not_installed makes the caller drop its queue.
82
+ return error.code === FLEET_NOT_INSTALLED ? {kind: "not_installed"} : {kind: "no_fleet_route"}
83
+ case 413:
84
+ return {kind: "batch_too_large"}
85
+ case 400:
86
+ return {
87
+ kind: "batch_invalid",
88
+ code: typeof error.code === "string" && ERROR_CODE_PATTERN.test(error.code) ? error.code : "unknown",
89
+ }
90
+ case 403:
91
+ return {kind: "forbidden"}
92
+ // 503, 429, 408, 5xx, and anything else Core was not documented to say: keep the records and try later.
93
+ default:
94
+ return unavailable(error.retryAfterSeconds)
95
+ }
96
+ }
97
+
98
+ export class Fleet {
99
+ constructor(private readonly http: HttpClient) {}
100
+
101
+ /**
102
+ * Reports one batch of Fleet records to the phone's own Core.
103
+ *
104
+ * Rejects in exactly two cases: an authentication failure (`AuthExpiredError`, or an `HttpError` with status 401),
105
+ * or an abort (an `AbortError`, or any error raised once `opts.signal` is aborted). Every other result is a
106
+ * {@link FleetReportOutcome}, including network failures, timeouts, a connection that drops while the response
107
+ * body is being read, a body that is not a valid acknowledgement, and unexpected errors, which are
108
+ * `{kind: "unavailable", retryAfterSeconds: null}`.
109
+ *
110
+ * The request is sent idempotently, so the HTTP client retries a transient network failure before the body is
111
+ * read; that is safe because Fleet records are idempotent by ID.
112
+ *
113
+ * `opts.bearer` sends exactly that Core token instead of asking the token source, for a caller that has already
114
+ * checked whose token it is.
115
+ */
116
+ async report(batch: FleetReport, opts?: {signal?: AbortSignal; bearer?: string}): Promise<FleetReportOutcome> {
117
+ const signal = opts?.signal
118
+ const bearer = opts?.bearer
119
+ let body: unknown
120
+ try {
121
+ body = await this.http.post<unknown>(FLEET_REPORT_PATH, batch, {
122
+ idempotent: true,
123
+ ...(signal ? {signal} : {}),
124
+ ...(bearer ? {bearer} : {}),
125
+ })
126
+ } catch (error) {
127
+ // The caller stopped the request: that is its answer, whatever the transport happened to report.
128
+ if (signal?.aborted || isAbortError(error) || isAuthFailure(error)) throw error
129
+ if (error instanceof HttpError) return outcomeForHttpError(error)
130
+ // Anything else (a body that is not JSON, a socket that closed mid-body, a failing token source) is no answer.
131
+ return unavailable()
132
+ }
133
+
134
+ const ack = readAck(body)
135
+ return ack ? {kind: "results", ack} : unavailable()
136
+ }
137
+ }
138
+
139
+ function isObject(value: unknown): value is Record<string, unknown> {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value)
141
+ }
142
+
143
+ function isWholeNumber(value: unknown): value is number {
144
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
145
+ }
146
+
147
+ /**
148
+ * Reads an acknowledgement the way a client already in the field must: it ignores keys it does not know, reads a
149
+ * status it does not know as `retryable`, and keeps a rejection whose reason it does not know. A newer Fleet can
150
+ * therefore add to the answer without this client reading all of it as no answer and resending the same batch
151
+ * forever. What it cannot do without is the shape that ties a result to a record: a `results` array of objects,
152
+ * each with a whole-number `index`, a text `status`, and a `recordId` that is text when present. Anything else is
153
+ * null, which the caller treats as no answer.
154
+ *
155
+ * Producers are still held to the strict `FleetReportAckSchema` in `@mentra/cloud-protocol`.
156
+ */
157
+ function readAck(body: unknown): FleetAck | null {
158
+ if (!isObject(body) || body.schemaVersion !== FLEET_SCHEMA_VERSION || !Array.isArray(body.results)) return null
159
+ const results: FleetAckResult[] = []
160
+ for (const raw of body.results) {
161
+ if (!isObject(raw) || !isWholeNumber(raw.index) || typeof raw.status !== "string") return null
162
+ if (raw.recordId !== undefined && typeof raw.recordId !== "string") return null
163
+ const status = (RESULT_STATUSES as readonly string[]).includes(raw.status)
164
+ ? (raw.status as FleetAckResult["status"])
165
+ : "retryable"
166
+ const result: FleetAckResult = {index: raw.index, status}
167
+ if (raw.recordId !== undefined) result.recordId = raw.recordId
168
+ if (status === "rejected" && typeof raw.reasonCode === "string" && ERROR_CODE_PATTERN.test(raw.reasonCode)) {
169
+ result.reasonCode = raw.reasonCode
170
+ }
171
+ if (isWholeNumber(raw.retryAfterSeconds)) result.retryAfterSeconds = raw.retryAfterSeconds
172
+ results.push(result)
173
+ }
174
+ return {schemaVersion: FLEET_SCHEMA_VERSION, results}
175
+ }