@mentra/cloud-client 0.1.0-beta.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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @fileoverview Read the claims out of a JWT without verifying its signature.
3
+ *
4
+ * The client is not a security boundary for its own token: the cloud verifies
5
+ * the signature on every call. So here we only need to read claims the client
6
+ * already trusts (its own `mentraUserId`, `tenant_id`, and `exp` for the refresh
7
+ * timing), and we deliberately skip signature checks. Doing a real verification
8
+ * would mean shipping the cloud's public keys to the device, which buys nothing.
9
+ *
10
+ * See docs/issues/004-cloud-client/design.md ("cloud.auth", identity) and
11
+ * docs/issues/001-cloud-core/auth/spec.md (access-token claims).
12
+ */
13
+
14
+ /**
15
+ * The claims this client reads off an access token.
16
+ *
17
+ * `sub` is the `mentraUserId`, `tenant_id` is the issuing OEM, and `exp` is the
18
+ * Unix-seconds expiry used to decide when to refresh. The index signature keeps
19
+ * the rest of the claims (`sessionId`, `jti`, `aud`, `iss`) accessible without
20
+ * naming each one, since the client only acts on these three.
21
+ */
22
+ export interface JwtClaims {
23
+ sub: string;
24
+ tenant_id: string;
25
+ exp: number;
26
+ [k: string]: unknown;
27
+ }
28
+
29
+ /**
30
+ * Decode one base64url segment to a UTF-8 string.
31
+ *
32
+ * JWT segments are base64url (with `-`/`_` and no padding), which `atob` does
33
+ * not accept directly, so we translate to standard base64 and re-pad first. We
34
+ * avoid Node's `Buffer` on purpose: this code also runs on the phone (React
35
+ * Native / Hermes), where `Buffer` is not guaranteed but `atob` is. We then
36
+ * rebuild the UTF-8 bytes by hand because `atob` yields a binary (latin1)
37
+ * string, so a multi-byte claim value (a non-ASCII display name, say) would be
38
+ * mangled if read directly.
39
+ */
40
+ function decodeBase64UrlToUtf8(segment: string): string {
41
+ const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
42
+ // Re-pad to a multiple of 4 so `atob` accepts the input.
43
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
44
+
45
+ const binary = atob(padded);
46
+ const bytes = new Uint8Array(binary.length);
47
+ for (let i = 0; i < binary.length; i++) {
48
+ bytes[i] = binary.charCodeAt(i);
49
+ }
50
+ // TextDecoder exists on both a modern phone and a modern server, and turns the
51
+ // raw bytes back into a proper UTF-8 string.
52
+ return new TextDecoder().decode(bytes);
53
+ }
54
+
55
+ /**
56
+ * Pull the claims out of a JWT's payload segment.
57
+ *
58
+ * A JWT is three dot-separated base64url segments (`header.payload.signature`);
59
+ * the claims live in the middle one. We throw a plain `Error` on a malformed
60
+ * token rather than returning a partial object, so a caller never acts on
61
+ * half-decoded identity.
62
+ */
63
+ export function decodeClaims(jwt: string): JwtClaims {
64
+ const segments = jwt.split(".");
65
+ if (segments.length < 2) {
66
+ throw new Error("decodeClaims: not a JWT (expected at least two segments)");
67
+ }
68
+
69
+ let claims: unknown;
70
+ try {
71
+ claims = JSON.parse(decodeBase64UrlToUtf8(segments[1]!));
72
+ } catch {
73
+ // Either the segment is not valid base64url or the payload is not JSON.
74
+ throw new Error("decodeClaims: JWT payload is not valid base64url JSON");
75
+ }
76
+
77
+ if (typeof claims !== "object" || claims === null) {
78
+ throw new Error("decodeClaims: JWT payload is not an object");
79
+ }
80
+
81
+ return claims as JwtClaims;
82
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @fileoverview Token state for `cloud.auth`: the in-memory access token, the
3
+ * persisted refresh token, and a single-flight lock.
4
+ *
5
+ * Split out from `auth.ts` so the storage details and the de-duplication lock
6
+ * live in one place. Two facts drive the design:
7
+ *
8
+ * - Only the refresh token is persisted (through the injected `KeyValueStore`),
9
+ * so a relaunch can re-mint an access token without a fresh login. The access
10
+ * token stays in memory: it is short-lived (~1h) and re-derivable.
11
+ * - Refreshes and miniapp-token mints must be de-duplicated. A reconnect storm
12
+ * can ask for a token from many callers at once; without the lock, each would
13
+ * fire its own `/refresh` and the rotation would invalidate the others'.
14
+ *
15
+ * Security: the access token is never written to storage and neither token is
16
+ * ever logged.
17
+ *
18
+ * See docs/issues/004-cloud-client/design.md ("cloud.auth").
19
+ */
20
+ import type { KeyValueStore } from "../../transports";
21
+ import { decodeClaims } from "./jwt";
22
+
23
+ /**
24
+ * Storage key for the persisted refresh token.
25
+ *
26
+ * Namespaced so it cannot collide with anything else a host keeps in the same
27
+ * secure store.
28
+ */
29
+ const REFRESH_TOKEN_KEY = "mentra.cloud-client.refreshToken";
30
+
31
+ /** The access token plus its Unix-seconds expiry, held in memory only. */
32
+ interface AccessTokenState {
33
+ accessToken: string;
34
+ exp: number;
35
+ }
36
+
37
+ export class TokenStore {
38
+ private readonly storage: KeyValueStore;
39
+
40
+ /** The current access token, or null before the first exchange/refresh. */
41
+ private access: AccessTokenState | null = null;
42
+
43
+ /**
44
+ * In-flight operations keyed by a caller-chosen string.
45
+ *
46
+ * Holding the promise (not just a boolean) lets every concurrent caller await
47
+ * the same result, which is the whole point of single-flight here.
48
+ */
49
+ private readonly inFlight = new Map<string, Promise<unknown>>();
50
+
51
+ constructor(deps: { storage: KeyValueStore }) {
52
+ this.storage = deps.storage;
53
+ }
54
+
55
+ /**
56
+ * The current in-memory access token and its expiry, or null if none.
57
+ *
58
+ * Synchronous because the access token never round-trips through storage; the
59
+ * caller (auth) decides whether it is still fresh using `exp`.
60
+ */
61
+ current(): AccessTokenState | null {
62
+ return this.access;
63
+ }
64
+
65
+ /**
66
+ * Record a freshly exchanged or refreshed token pair.
67
+ *
68
+ * The access token is decoded for its `exp` so callers can check freshness
69
+ * without re-parsing; the refresh token is persisted so it survives a
70
+ * relaunch. We read `exp` here (rather than in the caller) to keep all the
71
+ * token-state bookkeeping in one place.
72
+ */
73
+ async save(tokens: { accessToken: string; refreshToken: string }): Promise<void> {
74
+ this.access = {
75
+ accessToken: tokens.accessToken,
76
+ exp: decodeClaims(tokens.accessToken).exp,
77
+ };
78
+ await this.storage.set(REFRESH_TOKEN_KEY, tokens.refreshToken);
79
+ }
80
+
81
+ /**
82
+ * The persisted refresh token, or null if none has ever been saved.
83
+ *
84
+ * Read from storage every time (not cached) so a token rotated by another
85
+ * process or restored out of band is always the one used.
86
+ */
87
+ refreshToken(): Promise<string | null> {
88
+ return this.storage.get(REFRESH_TOKEN_KEY);
89
+ }
90
+
91
+ /**
92
+ * Drop only the in-memory access token, keeping the persisted refresh token.
93
+ *
94
+ * Called when the cloud rejects an access token the store still believes is
95
+ * fresh (clock skew, or a mid-session revoke surfaced as AUTH_EXPIRED): the
96
+ * next `current()` returns null so the caller refreshes against the still-good
97
+ * refresh token, rather than handing back the rejected token again.
98
+ */
99
+ invalidateAccess(): void {
100
+ this.access = null;
101
+ }
102
+
103
+ /**
104
+ * Clear all token state, in memory and in storage.
105
+ *
106
+ * Called when the refresh token is dead, so a later attempt does not keep
107
+ * presenting a known-bad token to the cloud.
108
+ */
109
+ async clear(): Promise<void> {
110
+ this.access = null;
111
+ await this.storage.delete(REFRESH_TOKEN_KEY);
112
+ }
113
+
114
+ /**
115
+ * Run `fn` once even if several callers ask for the same `key` at the same
116
+ * time, handing all of them the single result.
117
+ *
118
+ * The entry is removed once the work settles (whether it resolves or rejects)
119
+ * so the next call after completion starts fresh rather than replaying a stale
120
+ * promise. A rejection propagates to every waiter, which is correct: if a
121
+ * refresh failed, all callers waiting on it should see that failure.
122
+ */
123
+ singleFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
124
+ const existing = this.inFlight.get(key);
125
+ if (existing) return existing as Promise<T>;
126
+
127
+ const promise = (async () => {
128
+ try {
129
+ return await fn();
130
+ } finally {
131
+ this.inFlight.delete(key);
132
+ }
133
+ })();
134
+
135
+ this.inFlight.set(key, promise);
136
+ return promise;
137
+ }
138
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @fileoverview `cloud.core`: the device's stateless v2 REST calls.
3
+ *
4
+ * This is the simplest module: no live connection and no session state, so any
5
+ * cloud pod can serve any call here. Each request goes through the shared HTTP
6
+ * helper, which attaches the access token from `cloud.auth` as the Bearer, so
7
+ * core never touches credentials itself.
8
+ *
9
+ * Today it exposes the miniapp lookups the device needs at launch plus the
10
+ * unified report submission surface. It is meant to grow as miniapp-service and
11
+ * other core resources are specced.
12
+ *
13
+ * Guardrail: this is device-facing only. It deliberately carries none of the
14
+ * Dev Console / OEM Portal / store web UI surface; those are separate clients.
15
+ *
16
+ * See docs/issues/004-cloud-client/spec.md ("cloud.core") and design.md
17
+ * ("src/modules/core/core.ts").
18
+ */
19
+ import type { HttpClient } from "../../http";
20
+ import {
21
+ Reports,
22
+ type AddReportArtifactsResult,
23
+ type ReportAttachmentInput,
24
+ type ReportLogEntry,
25
+ type ReportStatus,
26
+ type SubmitReportInput,
27
+ type SubmitReportResult,
28
+ } from "./reports";
29
+
30
+ export type {
31
+ AddReportArtifactsResult,
32
+ ReportAttachmentInput,
33
+ ReportContext,
34
+ ReportDetails,
35
+ ReportKind,
36
+ ReportLogEntry,
37
+ ReportStatus,
38
+ ReportSystemPriority,
39
+ ReportTrigger,
40
+ SubmitReportInput,
41
+ SubmitReportResult,
42
+ } from "./reports";
43
+
44
+ /**
45
+ * A single miniapp entry as returned by the listing.
46
+ *
47
+ * These shapes are owned by miniapp-service, which is not finalized yet, so they
48
+ * are defined here against the spec rather than imported. They are intentionally
49
+ * NOT taken from `@mentra/cloud-protocol`: that package is the live
50
+ * session wire contract (subscriptions, transcripts, the message unions), while
51
+ * a miniapp listing is a core REST resource with no place on the runtime wire.
52
+ * When miniapp-service locks its schema, move these to the shared package and
53
+ * import them here instead.
54
+ */
55
+ export interface MiniappListing {
56
+ /** The reverse-DNS identifier, for example "com.example.notes". */
57
+ packageName: string;
58
+ /** Human-readable name shown to the user. */
59
+ name: string;
60
+ /** The version offered to this user (the latest the user is entitled to). */
61
+ version: string;
62
+ /** Optional one-line summary for a launcher list. */
63
+ description?: string;
64
+ /** Optional icon URL for a launcher list. */
65
+ iconUrl?: string;
66
+ }
67
+
68
+ /**
69
+ * The manifest that ships inside a miniapp bundle.
70
+ *
71
+ * Same ownership note as `MiniappListing`: this is a miniapp-service shape,
72
+ * defined here against the spec until that schema is locked. The fields below
73
+ * are the minimum the device needs to identify and describe a fetched bundle;
74
+ * miniapp-service is expected to add permission and capability declarations.
75
+ */
76
+ export interface MiniappManifest {
77
+ packageName: string;
78
+ name: string;
79
+ version: string;
80
+ /** Optional permissions the miniapp declares it needs. */
81
+ permissions?: string[];
82
+ }
83
+
84
+ /** What `getBundle` resolves to: where to download the bundle, plus its manifest. */
85
+ export interface MiniappBundle {
86
+ /** A (typically signed, short-lived) URL the device downloads the bundle from. */
87
+ downloadUrl: string;
88
+ /** The concrete version resolved, important when the caller omitted `version`. */
89
+ version: string;
90
+ manifest: MiniappManifest;
91
+ }
92
+
93
+ export type PreinstalledInstallPolicy =
94
+ | "install_once"
95
+ | "keep_updated"
96
+ | "mandatory";
97
+
98
+ export interface PreinstalledMiniappRegistryEntry {
99
+ packageName: string;
100
+ version: string;
101
+ bundleUrl: string;
102
+ bundleSha256: string;
103
+ required: boolean;
104
+ installPolicy: PreinstalledInstallPolicy;
105
+ channel: string;
106
+ minMobileVersion?: string;
107
+ maxMobileVersion?: string;
108
+ tenantId?: string;
109
+ }
110
+
111
+ export interface PreinstalledMiniappRegistry {
112
+ generatedAt: string;
113
+ entries: PreinstalledMiniappRegistryEntry[];
114
+ }
115
+
116
+ /** The dependencies `cloud.core` is wired with. */
117
+ export interface CoreDeps {
118
+ http: HttpClient;
119
+ }
120
+
121
+ /**
122
+ * `cloud.core`. Stateless REST grouped by resource (only `miniapps` so far).
123
+ *
124
+ * The grouping is exposed as a plain object so callers write
125
+ * `cloud.core.miniapps.list()`, matching the public `CoreModule` contract in the
126
+ * spec. Methods are bound in the constructor so destructuring `miniapps` keeps
127
+ * working.
128
+ */
129
+ export class Core {
130
+ readonly miniapps: {
131
+ list(): Promise<MiniappListing[]>;
132
+ getBundle(packageName: string, version?: string): Promise<MiniappBundle>;
133
+ getRegistry(opts?: { environment?: string }): Promise<PreinstalledMiniappRegistry>;
134
+ };
135
+ readonly reports: {
136
+ submit(input: SubmitReportInput): Promise<SubmitReportResult>;
137
+ addLogs(
138
+ reportId: string,
139
+ source: string,
140
+ entries: ReportLogEntry[],
141
+ ): Promise<AddReportArtifactsResult>;
142
+ addScreenshots(
143
+ reportId: string,
144
+ images: ReportAttachmentInput[],
145
+ ): Promise<AddReportArtifactsResult>;
146
+ complete(reportId: string): Promise<{ status: ReportStatus }>;
147
+ };
148
+
149
+ constructor(deps: CoreDeps) {
150
+ const { http } = deps;
151
+ const reports = new Reports({ http });
152
+
153
+ this.miniapps = {
154
+ /**
155
+ * List the miniapps available to the authenticated user.
156
+ *
157
+ * A GET so it is naturally safe to retry on a transient network error,
158
+ * which the shared HTTP helper does for us.
159
+ */
160
+ list(): Promise<MiniappListing[]> {
161
+ return http.get<MiniappListing[]>("/api/client/miniapps");
162
+ },
163
+
164
+ /**
165
+ * Fetch the downloadable bundle for one miniapp.
166
+ *
167
+ * When `version` is omitted the cloud resolves the version the user is
168
+ * entitled to (usually the latest); the resolved value comes back in the
169
+ * response so the caller can record exactly what it got. The version is
170
+ * passed as a query parameter and URL-encoded so an unusual version string
171
+ * cannot break the path.
172
+ */
173
+ getBundle(packageName: string, version?: string): Promise<MiniappBundle> {
174
+ const base = `/api/client/miniapps/${encodeURIComponent(packageName)}/bundle`;
175
+ const path =
176
+ version === undefined
177
+ ? base
178
+ : `${base}?version=${encodeURIComponent(version)}`;
179
+ return http.get<MiniappBundle>(path);
180
+ },
181
+
182
+ /**
183
+ * Fetch the admin-managed preinstalled miniapp registry for this device.
184
+ *
185
+ * The mobile client owns reconciliation: Core only returns the desired
186
+ * bundle versions and install policy for the current user/OEM/channel.
187
+ */
188
+ getRegistry(opts?: { environment?: string }): Promise<PreinstalledMiniappRegistry> {
189
+ const query = opts?.environment
190
+ ? `?environment=${encodeURIComponent(opts.environment)}`
191
+ : "";
192
+ return http.get<PreinstalledMiniappRegistry>(`/api/client/miniapps/registry${query}`);
193
+ },
194
+ };
195
+ this.reports = {
196
+ submit: reports.submit.bind(reports),
197
+ addLogs: reports.addLogs.bind(reports),
198
+ addScreenshots: reports.addScreenshots.bind(reports),
199
+ complete: reports.complete.bind(reports),
200
+ };
201
+ }
202
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @fileoverview Cloud V2 user/system reporting API.
3
+ *
4
+ * A report is the single device-facing primitive for manual bug reports,
5
+ * feature/general feedback, and automatic runtime reports. The engine supplies
6
+ * diagnostic context and artifacts; host UI supplies only user-visible content.
7
+ */
8
+
9
+ import type { HttpClient } from "../../http";
10
+
11
+ const REPORTS_PATH = "/api/client/reports";
12
+
13
+ export type ReportKind = "bug" | "feedback" | "automatic";
14
+ export type ReportStatus = "collecting" | "ready" | "closed";
15
+ export type ReportSystemPriority = "low" | "medium" | "high" | "critical";
16
+
17
+ interface BaseReportTrigger {
18
+ source: string;
19
+ reason: string;
20
+ sourceAppletPackageName?: string;
21
+ sourceAppletName?: string;
22
+ }
23
+
24
+ export type ReportTrigger =
25
+ | (BaseReportTrigger & { type: "manual" })
26
+ | (BaseReportTrigger & { type: "automatic" });
27
+
28
+ export interface ReportDetails {
29
+ actualBehavior: string;
30
+ expectedBehavior?: string;
31
+ userSeverity?: 1 | 2 | 3 | 4 | 5;
32
+ systemPriority?: ReportSystemPriority;
33
+ contactEmail?: string;
34
+ }
35
+
36
+ export interface ReportContext extends Record<string, unknown> {
37
+ app?: Record<string, unknown>;
38
+ phone?: Record<string, unknown>;
39
+ glasses?: Record<string, unknown>;
40
+ runtime?: Record<string, unknown>;
41
+ apps?: Record<string, unknown>;
42
+ settings?: Record<string, unknown>;
43
+ }
44
+
45
+ export type SubmitReportInput =
46
+ | {
47
+ kind: "bug";
48
+ trigger: ReportTrigger;
49
+ report: ReportDetails;
50
+ context: ReportContext;
51
+ }
52
+ | {
53
+ kind: "automatic";
54
+ trigger: Extract<ReportTrigger, { type: "automatic" }>;
55
+ report: ReportDetails;
56
+ context: ReportContext;
57
+ }
58
+ | {
59
+ kind: "feedback";
60
+ feedback: string | Record<string, unknown>;
61
+ context: ReportContext;
62
+ };
63
+
64
+ export interface SubmitReportResult {
65
+ reportId: string;
66
+ status: ReportStatus;
67
+ }
68
+
69
+ export interface ReportLogEntry {
70
+ timestamp: number;
71
+ level: string;
72
+ message: string;
73
+ source?: string;
74
+ }
75
+
76
+ export interface ReportAttachmentInput {
77
+ uri?: string;
78
+ fileName?: string | null;
79
+ mimeType?: string | null;
80
+ blob?: Blob;
81
+ }
82
+
83
+ export interface AddReportArtifactsResult {
84
+ stored: number;
85
+ }
86
+
87
+ export interface ReportsDeps {
88
+ http: HttpClient;
89
+ }
90
+
91
+ export class Reports {
92
+ private readonly http: HttpClient;
93
+
94
+ constructor(deps: ReportsDeps) {
95
+ this.http = deps.http;
96
+ }
97
+
98
+ submit(input: SubmitReportInput): Promise<SubmitReportResult> {
99
+ return this.http.post<SubmitReportResult>(REPORTS_PATH, input);
100
+ }
101
+
102
+ async addLogs(
103
+ reportId: string,
104
+ source: string,
105
+ entries: ReportLogEntry[],
106
+ ): Promise<AddReportArtifactsResult> {
107
+ return await this.http.post<AddReportArtifactsResult>(
108
+ `${REPORTS_PATH}/${encodeURIComponent(reportId)}/artifacts`,
109
+ {
110
+ type: "logs",
111
+ source,
112
+ entries,
113
+ },
114
+ );
115
+ }
116
+
117
+ addScreenshots(
118
+ reportId: string,
119
+ images: ReportAttachmentInput[],
120
+ ): Promise<AddReportArtifactsResult> {
121
+ const form = new FormData();
122
+ form.append("type", "screenshot");
123
+ form.append("source", "phone");
124
+ for (const image of images) {
125
+ const filename = image.fileName || `screenshot-${Date.now()}.jpg`;
126
+ const mimeType = image.mimeType || "image/jpeg";
127
+ if (image.blob) {
128
+ form.append("files", image.blob, filename);
129
+ continue;
130
+ }
131
+ if (!image.uri) {
132
+ throw new Error("report screenshot requires either blob or uri");
133
+ }
134
+ form.append("files", {
135
+ uri: image.uri,
136
+ name: filename,
137
+ type: mimeType,
138
+ } as unknown as Blob);
139
+ }
140
+
141
+ return this.http.postForm<AddReportArtifactsResult>(
142
+ `${REPORTS_PATH}/${encodeURIComponent(reportId)}/artifacts`,
143
+ form,
144
+ );
145
+ }
146
+
147
+ complete(reportId: string): Promise<{ status: ReportStatus }> {
148
+ return this.http.post<{ status: ReportStatus }>(
149
+ `${REPORTS_PATH}/${encodeURIComponent(reportId)}/complete`,
150
+ {},
151
+ );
152
+ }
153
+ }