@mentra/cloud-client 0.1.0-dev.0

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/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @fileoverview Public entry for `@mentra/cloud-client` (the root, shared build).
3
+ *
4
+ * This is the platform-agnostic import: it exposes `CloudClient` plus the public
5
+ * config, transport, error, and logger types a host needs to construct and use
6
+ * it. The `react-native` and `node` subpath imports wrap this with their own
7
+ * transports pre-wired.
8
+ *
9
+ * Wire/protocol types (`AudioSubscription`, `TranscriptionData`, the message
10
+ * unions) are NOT re-exported here: a host imports those from
11
+ * `@mentra/cloud-protocol` directly, so there is one source of truth and
12
+ * the client cannot drift from what the cloud accepts.
13
+ *
14
+ * See docs/issues/004-cloud-client/spec.md and design.md.
15
+ */
16
+
17
+ // The top-level object. Implemented in ./client by the client agent; re-exported
18
+ // here so the public import is `@mentra/cloud-client`, not a deep path.
19
+ export { CloudClient } from "./client";
20
+
21
+ // Construction contract.
22
+ export type {
23
+ CloudClientConfig,
24
+ AuthConfig,
25
+ SubjectTokenType,
26
+ } from "./config";
27
+
28
+ // The platform pieces a host (or a platform wrapper) supplies.
29
+ export type {
30
+ CloudClientTransports,
31
+ WebSocketLike,
32
+ UdpSocketLike,
33
+ KeyValueStore,
34
+ } from "./transports";
35
+
36
+ // Local error types a host can branch on with `instanceof`.
37
+ export { CloudClientError, HttpError, AuthExpiredError } from "./errors";
38
+
39
+ // The logging hook a host can implement to route library logs.
40
+ export { noopLogger } from "./logger";
41
+ export type { Logger } from "./logger";
42
+
43
+ // Runtime lifecycle state exposed to hosts for UI/debugging and fallback policy.
44
+ export type {
45
+ PreinstalledInstallPolicy,
46
+ PreinstalledMiniappRegistry,
47
+ PreinstalledMiniappRegistryEntry,
48
+ } from "./modules/core/core";
49
+
50
+ export type {
51
+ RuntimeAudioTransport,
52
+ RuntimeSnapshot,
53
+ RuntimeStatus,
54
+ RuntimeTtsSpeakOptions,
55
+ RuntimeTtsSpeechSource,
56
+ } from "./modules/runtime/runtime";
57
+
58
+ export type {
59
+ AddReportArtifactsResult,
60
+ ReportAttachmentInput,
61
+ ReportContext,
62
+ ReportDetails,
63
+ ReportKind,
64
+ ReportLogEntry,
65
+ ReportStatus,
66
+ ReportSystemPriority,
67
+ ReportTrigger,
68
+ SubmitReportInput,
69
+ SubmitReportResult,
70
+ } from "./modules/core/core";
package/src/logger.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fileoverview The logging interface and a no-op default.
3
+ *
4
+ * One logger is owned by `CloudClient` and passed down to every module, so a
5
+ * host can route all cloud-client logs wherever it wants. The interface is kept
6
+ * minimal (four levels, an optional structured-meta object) so it is trivial to
7
+ * adapt any host logger to it.
8
+ *
9
+ * Security: never pass a token, refresh token, or encryption key into any of
10
+ * these calls. The library does not log credentials, and neither should hosts.
11
+ *
12
+ * See docs/issues/004-cloud-client/design.md.
13
+ */
14
+
15
+ /** The shape a host must provide (or accept the no-op below). */
16
+ export interface Logger {
17
+ debug(msg: string, meta?: object): void;
18
+ info(msg: string, meta?: object): void;
19
+ warn(msg: string, meta?: object): void;
20
+ error(msg: string, meta?: object): void;
21
+ }
22
+
23
+ /**
24
+ * The default logger when a host supplies none: it drops everything.
25
+ *
26
+ * A silent default means the library never prints to a host's console
27
+ * uninvited; a host opts into logging by passing its own `Logger`.
28
+ */
29
+ export const noopLogger: Logger = {
30
+ debug() {},
31
+ info() {},
32
+ warn() {},
33
+ error() {},
34
+ };
@@ -0,0 +1,548 @@
1
+ /**
2
+ * @fileoverview `cloud.auth`: the one owner of credentials.
3
+ *
4
+ * It owns the credential lifecycle for the configured services. Core tokens are
5
+ * exchanged/refreshed through Cloud Core. Runtime tokens are either supplied by
6
+ * the host/OEM or explicitly minted by Core's `/runtime-token` broker endpoint.
7
+ *
8
+ * Token lifecycle:
9
+ * - First use exchanges the configured subject token at `/exchange` for an
10
+ * access + refresh pair (RFC 8693). A `getSubjectToken` callback variant
11
+ * fetches the subject token on demand for subject tokens that themselves
12
+ * expire. An `accessToken`/`refreshToken` variant skips straight to refresh.
13
+ * - Near expiry (within a 60s margin) it refreshes at `/refresh` and rotates.
14
+ * - Exchange, refresh, and each per-miniapp mint are single-flighted, so a
15
+ * reconnect storm cannot fire a burst of competing requests (and a rotated
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.
21
+ *
22
+ * Security: the access token is never written to storage, never given to a
23
+ * miniapp, and no token is ever logged.
24
+ *
25
+ * See docs/issues/004-cloud-client/spec.md ("cloud.auth"), design.md, and
26
+ * docs/issues/001-cloud-core/auth/spec.md (endpoints + token shapes).
27
+ */
28
+ import type {
29
+ AuthConfig,
30
+ CoreAuthConfig,
31
+ RuntimeAuthConfig,
32
+ SubjectTokenType,
33
+ } from "../../config";
34
+ import type { HttpClient } from "../../http";
35
+ import type { Logger } from "../../logger";
36
+ import { AuthExpiredError } from "../../errors";
37
+ import { decodeClaims } from "./jwt";
38
+ import { TokenStore } from "./token-store";
39
+
40
+ /**
41
+ * The public auth surface a host uses, from spec.md.
42
+ *
43
+ * Declared here (rather than imported) because it is a client-side module
44
+ * contract, not a wire type: the protocol package owns the on-the-wire shapes,
45
+ * this owns the module shapes. `cloud.runtime` and `cloud.core` depend only on
46
+ * `getRuntimeToken` / `getCoreToken`.
47
+ */
48
+ export interface AuthModule {
49
+ // current runtime token, refreshing as needed. Pass
50
+ // `{ forceRefresh: true }` to bypass the in-memory cache and refresh now, for
51
+ // the case where the cloud rejected a token the client still thinks is fresh
52
+ // (clock skew or a mid-session revoke surfaced as AUTH_EXPIRED).
53
+ getRuntimeToken(opts?: { forceRefresh?: boolean }): Promise<string>;
54
+ // current Core token, refreshing as needed (Core-backed mode only).
55
+ getCoreToken(opts?: { forceRefresh?: boolean }): Promise<string>;
56
+ // a miniapp-scoped token, cached per packageName and re-minted before expiry
57
+ getMiniappToken(
58
+ packageName: string,
59
+ opts?: { minTtlMs?: number; devAttestation?: string },
60
+ ): Promise<{ token: string; expiresAt: number }>;
61
+ // Core-owned user/oem identity, read from the Core access token.
62
+ // Runtime-only deployments do not expose this surface.
63
+ readonly identity: { mentraUserId: string; tenantId: string };
64
+ // refresh failed; the host must send the user back through login
65
+ onExpired(handler: () => void): () => void;
66
+ }
67
+
68
+ /** RFC 8693 token-exchange grant type for the `/exchange` call. */
69
+ const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
70
+
71
+ /**
72
+ * Map a config `SubjectTokenType` to the RFC 8693 `subject_token_type` URN the
73
+ * cloud expects.
74
+ *
75
+ * All three supported subject tokens (an OEM-signed JWT, a Mentra core token, a
76
+ * Supabase session) are presented as a JWT and verified by their own `iss` /
77
+ * verification path on the cloud, so they share the one JWT token-type URN. The
78
+ * mapping is kept explicit (rather than hard-coding one URN) so that if the
79
+ * cloud later wants distinct URNs per source, this is the single place to widen.
80
+ */
81
+ const SUBJECT_TOKEN_TYPE_URN: Record<SubjectTokenType, string> = {
82
+ "oem-jwt": "urn:ietf:params:oauth:token-type:jwt",
83
+ "mentra-core": "urn:ietf:params:oauth:token-type:jwt",
84
+ supabase: "urn:ietf:params:oauth:token-type:jwt",
85
+ };
86
+
87
+ /** Seconds of headroom before `exp` at which we proactively refresh/re-mint. */
88
+ const EXPIRY_MARGIN_SECONDS = 60;
89
+
90
+ /** The cloud's token endpoints, relative to the core base URL. */
91
+ const EXCHANGE_PATH = "/api/client/auth/exchange";
92
+ const REFRESH_PATH = "/api/client/auth/refresh";
93
+ const RUNTIME_TOKEN_PATH = "/api/client/auth/runtime-token";
94
+ const MINIAPP_TOKEN_PATH = "/api/client/auth/miniapp-token";
95
+
96
+ /** Single-flight keys. The miniapp key is suffixed per packageName below. */
97
+ const FLIGHT_ACCESS = "access-token";
98
+ const FLIGHT_RUNTIME = "runtime-token";
99
+ const MINIAPP_FLIGHT_PREFIX = "miniapp-token:";
100
+
101
+ /** The cloud's RFC-shaped token response from `/exchange` and `/refresh`. */
102
+ interface TokenResponse {
103
+ access_token: string;
104
+ refresh_token: string;
105
+ token_type: string;
106
+ expires_in: number;
107
+ }
108
+
109
+ /** A cached miniapp token plus its absolute expiry (Unix seconds). */
110
+ interface MiniappTokenEntry {
111
+ token: string;
112
+ expiresAt: number;
113
+ }
114
+
115
+ interface RuntimeTokenResponse {
116
+ access_token: string;
117
+ token_type: string;
118
+ expires_in: number;
119
+ }
120
+
121
+ interface RuntimeTokenEntry {
122
+ token: string;
123
+ expiresAt: number;
124
+ }
125
+
126
+ export class Auth implements AuthModule {
127
+ private readonly http?: HttpClient;
128
+ private readonly store: TokenStore;
129
+ private readonly coreConfig?: CoreAuthConfig;
130
+ private readonly runtimeConfig: RuntimeAuthConfig;
131
+ private readonly logger: Logger;
132
+ /**
133
+ * Core base URL for the form-encoded `/exchange` and `/refresh` calls.
134
+ *
135
+ * These two endpoints take `application/x-www-form-urlencoded` bodies and
136
+ * present the subject/refresh token in the body (not as a Bearer), so they go
137
+ * through `fetch` directly rather than the JSON-only injected `HttpClient`. In
138
+ * runtime-only mode this is absent by design: Core identity, miniapp token
139
+ * minting, and miniapp auto-auth are Core-backed features.
140
+ */
141
+ private readonly baseUrl?: string;
142
+
143
+ /** Miniapp tokens cached per packageName until near expiry. */
144
+ private readonly miniappCache = new Map<string, MiniappTokenEntry>();
145
+ /** Core-brokered runtime token cache. */
146
+ private runtimeToken: RuntimeTokenEntry | null = null;
147
+
148
+ /** Registered `onExpired` handlers. */
149
+ private readonly expiredHandlers = new Set<() => void>();
150
+
151
+ /**
152
+ * Latches once `onExpired` has fired, so a dead refresh token notifies the
153
+ * host exactly once instead of on every subsequent call.
154
+ */
155
+ private expiredFired = false;
156
+
157
+ constructor(deps: {
158
+ http?: HttpClient;
159
+ store: TokenStore;
160
+ config: AuthConfig;
161
+ logger: Logger;
162
+ baseUrl?: string;
163
+ }) {
164
+ this.http = deps.http;
165
+ this.store = deps.store;
166
+ this.coreConfig = deps.config.core;
167
+ this.runtimeConfig = deps.config.runtime;
168
+ this.logger = deps.logger;
169
+ this.baseUrl = deps.baseUrl;
170
+ }
171
+
172
+ /**
173
+ * Return a currently valid access token, exchanging or refreshing as needed.
174
+ *
175
+ * The happy path is a cache hit: an in-memory token still outside the expiry
176
+ * margin is returned with no network call. Otherwise we obtain one through a
177
+ * single-flight so concurrent callers share the same request.
178
+ */
179
+ async getRuntimeToken(opts?: { forceRefresh?: boolean }): Promise<string> {
180
+ if ("getToken" in this.runtimeConfig) {
181
+ return this.runtimeConfig.getToken(opts);
182
+ }
183
+
184
+ if (opts?.forceRefresh) {
185
+ this.runtimeToken = null;
186
+ } else if (this.runtimeToken && !this.isExpiring(this.runtimeToken.expiresAt)) {
187
+ return this.runtimeToken.token;
188
+ }
189
+
190
+ return this.store.singleFlight(FLIGHT_RUNTIME, () =>
191
+ this.obtainCoreBrokeredRuntimeToken(),
192
+ );
193
+ }
194
+
195
+ async getCoreToken(opts?: { forceRefresh?: boolean }): Promise<string> {
196
+ this.requireCoreConfig();
197
+ // A forced refresh drops the cached access token first, so the cache check
198
+ // below misses and we go straight to the single-flight refresh. The
199
+ // single-flight still de-dupes, so a burst of forced refreshes (one per
200
+ // reconnect attempt) collapses to one request.
201
+ if (opts?.forceRefresh) {
202
+ this.store.invalidateAccess();
203
+ } else {
204
+ const cached = this.store.current();
205
+ if (cached && !this.isExpiring(cached.exp)) {
206
+ return cached.accessToken;
207
+ }
208
+ }
209
+
210
+ // De-dupe: if a refresh/exchange is already running, await that one result.
211
+ return this.store.singleFlight(FLIGHT_ACCESS, () => this.obtainAccessToken());
212
+ }
213
+
214
+ /**
215
+ * Mint (or return a cached) miniapp-scoped token for `packageName`.
216
+ *
217
+ * Cached per packageName and re-minted only when near expiry, so a steady
218
+ * miniapp does not hit the cloud on every call. The mint is single-flighted
219
+ * per packageName so two near-simultaneous launches of the same miniapp share
220
+ * one request. The access token is the Bearer here and is never exposed to the
221
+ * miniapp; only the returned miniapp-scoped token is.
222
+ */
223
+ async getMiniappToken(
224
+ packageName: string,
225
+ opts?: { minTtlMs?: number; devAttestation?: string },
226
+ ): Promise<{ token: string; expiresAt: number }> {
227
+ const marginSeconds = this.tokenMarginSeconds(opts?.minTtlMs);
228
+ const cached = this.miniappCache.get(packageName);
229
+ if (cached && !this.isExpiring(cached.expiresAt, marginSeconds)) {
230
+ return { token: cached.token, expiresAt: cached.expiresAt };
231
+ }
232
+
233
+ return this.store.singleFlight(MINIAPP_FLIGHT_PREFIX + packageName, async () => {
234
+ // Re-check the cache inside the flight: a concurrent mint that resolved
235
+ // while we were queued may have already filled it.
236
+ const fresh = this.miniappCache.get(packageName);
237
+ if (fresh && !this.isExpiring(fresh.expiresAt, marginSeconds)) {
238
+ return { token: fresh.token, expiresAt: fresh.expiresAt };
239
+ }
240
+
241
+ const accessToken = await this.getCoreToken();
242
+ const http = this.requireCoreHttp();
243
+ const res = await http.post<MiniappTokenEntry>(
244
+ MINIAPP_TOKEN_PATH,
245
+ {
246
+ packageName,
247
+ ...(opts?.devAttestation ? { devAttestation: opts.devAttestation } : {}),
248
+ },
249
+ { bearer: accessToken },
250
+ );
251
+
252
+ const entry: MiniappTokenEntry = { token: res.token, expiresAt: res.expiresAt };
253
+ this.miniappCache.set(packageName, entry);
254
+ this.logger.debug("minted miniapp token", { packageName });
255
+ return { token: entry.token, expiresAt: entry.expiresAt };
256
+ });
257
+ }
258
+
259
+ /**
260
+ * The user + OEM identity, read straight off the Core access token's claims.
261
+ *
262
+ * Core owns client identity. Runtime-only tokens may carry identity claims for
263
+ * Runtime authorization/logging, but `cloud.auth.identity`, miniapp token
264
+ * minting, and miniapp auto-auth are intentionally unavailable without
265
+ * `auth.core` + `endpoints.core`.
266
+ *
267
+ * The claims are unverified base64 JSON (the cloud verifies the signature on
268
+ * every call, so the client need not). Throws if no Core access token has been
269
+ * obtained yet, since Core identity is meaningless before the first exchange.
270
+ */
271
+ get identity(): { mentraUserId: string; tenantId: string } {
272
+ this.requireCoreConfig();
273
+ const current = this.store.current();
274
+ if (!current) {
275
+ throw new AuthExpiredError("Core identity is unavailable before first Core sign-in");
276
+ }
277
+ const claims = decodeClaims(current.accessToken);
278
+ return { mentraUserId: claims.sub, tenantId: claims.tenant_id };
279
+ }
280
+
281
+ /**
282
+ * Register a handler invoked when a refresh fails and re-auth is required.
283
+ *
284
+ * Returns an unsubscribe function, matching the rest of the client's `on*`
285
+ * surface. Handlers fire at most once per dead-credential event (see
286
+ * `fireExpired`).
287
+ */
288
+ onExpired(handler: () => void): () => void {
289
+ this.expiredHandlers.add(handler);
290
+ return () => {
291
+ this.expiredHandlers.delete(handler);
292
+ };
293
+ }
294
+
295
+ // === internals ===
296
+
297
+ /**
298
+ * Decide whether a token is close enough to its `exp` that we should refresh
299
+ * now rather than risk handing out one that expires mid-flight.
300
+ *
301
+ * `exp` is Unix seconds (the JWT convention), so we compare against the clock
302
+ * in seconds and subtract the margin.
303
+ */
304
+ private isExpiring(expSeconds: number, marginSeconds = EXPIRY_MARGIN_SECONDS): boolean {
305
+ const nowSeconds = Math.floor(Date.now() / 1000);
306
+ return expSeconds - marginSeconds <= nowSeconds;
307
+ }
308
+
309
+ private tokenMarginSeconds(minTtlMs?: number): number {
310
+ if (!Number.isFinite(minTtlMs) || !minTtlMs || minTtlMs <= 0) {
311
+ return EXPIRY_MARGIN_SECONDS;
312
+ }
313
+ return Math.max(EXPIRY_MARGIN_SECONDS, Math.ceil(minTtlMs / 1000));
314
+ }
315
+
316
+ /**
317
+ * Obtain a fresh access token: refresh if we hold a refresh token, otherwise
318
+ * do the first-use exchange. Runs inside the single-flight from
319
+ * `getCoreToken`, so only one of these is ever in flight.
320
+ */
321
+ private async obtainAccessToken(): Promise<string> {
322
+ const refreshToken = await this.store.refreshToken();
323
+ if (refreshToken) {
324
+ try {
325
+ return await this.refresh(refreshToken, { deferExpired: this.canExchangeFreshSubject() });
326
+ } catch (err) {
327
+ if (this.canExchangeFreshSubject()) {
328
+ this.logger.info("refresh failed; exchanging fresh subject token");
329
+ try {
330
+ return await this.exchange();
331
+ } catch {
332
+ this.fireExpired();
333
+ }
334
+ }
335
+ throw err;
336
+ }
337
+ }
338
+ return this.exchange();
339
+ }
340
+
341
+ /**
342
+ * First-use exchange: trade the configured subject token for an access +
343
+ * refresh pair and save them.
344
+ *
345
+ * Three config shapes feed this: a raw subject token, a `getSubjectToken`
346
+ * callback (for subject tokens that expire before exchange, so we fetch one
347
+ * fresh each time), or a pre-exchanged access/refresh pair. The last shape has
348
+ * no subject token at all: we persist its refresh token and refresh, since we
349
+ * only reach `exchange` when no refresh token is stored.
350
+ */
351
+ private async exchange(): Promise<string> {
352
+ const config = this.requireCoreConfig();
353
+ // Pre-exchanged credentials: seed the store and refresh, no /exchange call.
354
+ if ("refreshToken" in config) {
355
+ await this.store.save({
356
+ accessToken: config.accessToken,
357
+ refreshToken: config.refreshToken,
358
+ });
359
+ // The seeded access token may already be near expiry, so refresh through
360
+ // the normal path to guarantee a fresh one.
361
+ return this.refresh(config.refreshToken);
362
+ }
363
+
364
+ const subject = await this.resolveSubjectToken(config);
365
+ const body = new URLSearchParams({
366
+ grant_type: TOKEN_EXCHANGE_GRANT,
367
+ subject_token: subject.token,
368
+ subject_token_type: SUBJECT_TOKEN_TYPE_URN[subject.type],
369
+ });
370
+
371
+ const tokens = await this.postForm(EXCHANGE_PATH, body, "exchange");
372
+ await this.store.save({
373
+ accessToken: tokens.access_token,
374
+ refreshToken: tokens.refresh_token,
375
+ });
376
+ // A successful exchange means credentials are live again; clear the latch so
377
+ // a future failure can notify the host once more.
378
+ this.expiredFired = false;
379
+ this.logger.info("exchanged subject token for access token");
380
+ return tokens.access_token;
381
+ }
382
+
383
+ /**
384
+ * Refresh near expiry: trade the stored refresh token for a new access token
385
+ * and a rotated refresh token, saving both.
386
+ *
387
+ * On failure (the refresh token is dead or revoked) we clear stored state.
388
+ * The caller either falls back to one fresh subject-token exchange (for
389
+ * on-demand subject-token configs) or fires `onExpired` once and surfaces an
390
+ * `AuthExpiredError`. We do not retry refresh forever: a dead refresh token
391
+ * will not heal on its own, and retrying would loop.
392
+ */
393
+ private async refresh(refreshToken: string, opts?: { deferExpired?: boolean }): Promise<string> {
394
+ const body = new URLSearchParams({
395
+ grant_type: "refresh_token",
396
+ refresh_token: refreshToken,
397
+ });
398
+
399
+ let tokens: TokenResponse;
400
+ try {
401
+ tokens = await this.postForm(REFRESH_PATH, body, "refresh");
402
+ } catch {
403
+ // The refresh token is unusable: drop it so we do not keep presenting a
404
+ // known-bad token.
405
+ await this.store.clear();
406
+ if (!opts?.deferExpired) {
407
+ this.fireExpired();
408
+ }
409
+ throw new AuthExpiredError("token refresh failed; re-auth required");
410
+ }
411
+
412
+ await this.store.save({
413
+ accessToken: tokens.access_token,
414
+ refreshToken: tokens.refresh_token,
415
+ });
416
+ this.expiredFired = false;
417
+ this.logger.debug("refreshed access token");
418
+ return tokens.access_token;
419
+ }
420
+
421
+ private canExchangeFreshSubject(): boolean {
422
+ return !!this.coreConfig && "getSubjectToken" in this.coreConfig;
423
+ }
424
+
425
+ /**
426
+ * Resolve the subject token to exchange, from either the static config field
427
+ * or the `getSubjectToken` callback.
428
+ *
429
+ * Only reached for the two non-pre-exchanged config shapes; the caller handles
430
+ * the pre-exchanged shape before us, so the final throw is just exhaustiveness.
431
+ */
432
+ private async resolveSubjectToken(
433
+ config: CoreAuthConfig,
434
+ ): Promise<{ token: string; type: SubjectTokenType }> {
435
+ if ("subjectToken" in config) {
436
+ return { token: config.subjectToken, type: config.subjectTokenType };
437
+ }
438
+ if ("getSubjectToken" in config) {
439
+ return config.getSubjectToken();
440
+ }
441
+ throw new AuthExpiredError("no subject token available to exchange");
442
+ }
443
+
444
+ private requireCoreConfig(): CoreAuthConfig {
445
+ if (!this.coreConfig) {
446
+ throw new AuthExpiredError("core auth is not configured; this API is unavailable in runtime-only mode");
447
+ }
448
+ return this.coreConfig;
449
+ }
450
+
451
+ private requireCoreHttp(): HttpClient {
452
+ if (!this.http) {
453
+ throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode");
454
+ }
455
+ return this.http;
456
+ }
457
+
458
+ private async obtainCoreBrokeredRuntimeToken(): Promise<string> {
459
+ const fresh = this.runtimeToken;
460
+ if (fresh && !this.isExpiring(fresh.expiresAt)) {
461
+ return fresh.token;
462
+ }
463
+
464
+ const coreToken = await this.getCoreToken();
465
+ const http = this.requireCoreHttp();
466
+ const res = await http.post<RuntimeTokenResponse>(
467
+ RUNTIME_TOKEN_PATH,
468
+ {},
469
+ { bearer: coreToken },
470
+ );
471
+ if (res.token_type !== "Bearer" || !res.access_token) {
472
+ throw new AuthExpiredError("core returned an invalid runtime token response");
473
+ }
474
+
475
+ const expiresAt = Math.floor(Date.now() / 1000) + res.expires_in;
476
+ this.runtimeToken = { token: res.access_token, expiresAt };
477
+ return res.access_token;
478
+ }
479
+
480
+ /**
481
+ * POST a form-encoded body to a token endpoint and parse the RFC token
482
+ * response.
483
+ *
484
+ * Uses `fetch` directly (not the injected JSON `HttpClient`) because these
485
+ * endpoints require `application/x-www-form-urlencoded` and present the
486
+ * subject/refresh token in the body, not as a Bearer header. We never log the
487
+ * body: it carries a token.
488
+ */
489
+ private async postForm(
490
+ path: string,
491
+ body: URLSearchParams,
492
+ label: string,
493
+ ): Promise<TokenResponse> {
494
+ const url = this.joinUrl(path);
495
+ const res = await fetch(url, {
496
+ method: "POST",
497
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
498
+ body: body.toString(),
499
+ });
500
+
501
+ if (!res.ok) {
502
+ // The body may carry an RFC `{ error, error_description }`, but we keep the
503
+ // thrown detail to the status + label so no token field can leak into a
504
+ // message a host might surface. The caller maps this to re-auth.
505
+ this.logger.warn("auth token request failed", { label, status: res.status });
506
+ throw new AuthExpiredError(`${label} request failed with status ${res.status}`);
507
+ }
508
+
509
+ return (await res.json()) as TokenResponse;
510
+ }
511
+
512
+ /**
513
+ * Join the core base URL and a path without a double or missing slash.
514
+ *
515
+ * Done by hand (not `new URL`) so a base that already carries a path prefix (a
516
+ * proxy mount point) is preserved, matching the shared HTTP helper's joining.
517
+ */
518
+ private joinUrl(path: string): string {
519
+ if (!this.baseUrl) {
520
+ throw new AuthExpiredError("core endpoint is not configured; this API is unavailable in runtime-only mode");
521
+ }
522
+ const base = this.baseUrl.replace(/\/+$/, "");
523
+ const suffix = path.replace(/^\/+/, "");
524
+ return `${base}/${suffix}`;
525
+ }
526
+
527
+ /**
528
+ * Notify every `onExpired` handler exactly once per dead-credential event.
529
+ *
530
+ * The latch prevents a flood of notifications when many queued calls all fail
531
+ * against the same dead refresh token; it resets on the next successful
532
+ * exchange/refresh so a later failure can notify again.
533
+ */
534
+ private fireExpired(): void {
535
+ if (this.expiredFired) return;
536
+ this.expiredFired = true;
537
+ for (const handler of this.expiredHandlers) {
538
+ try {
539
+ handler();
540
+ } catch (err) {
541
+ // A host handler throwing must not stop the others from running.
542
+ this.logger.error("onExpired handler threw", {
543
+ error: err instanceof Error ? err.message : String(err),
544
+ });
545
+ }
546
+ }
547
+ }
548
+ }