@mentra/cloud-client 3.3.0-dev.909 → 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 +2 -0
- package/package.json +2 -2
- package/src/errors.ts +27 -2
- package/src/http.ts +19 -1
- package/src/index.ts +8 -0
- package/src/modules/auth/auth.ts +216 -30
- package/src/modules/core/core.ts +17 -2
- package/src/modules/core/fleet.ts +175 -0
package/node/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/cloud-client",
|
|
3
|
-
"version": "3.3.0-dev.
|
|
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.
|
|
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(
|
|
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,
|
package/src/modules/auth/auth.ts
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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.
|
|
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
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.
|
|
518
|
-
} catch {
|
|
519
|
-
//
|
|
520
|
-
|
|
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
|
|
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
|
-
|
|
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 }
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
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
|
*
|
package/src/modules/core/core.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|