@mentra/cloud-protocol 3.4.0-dev.998 → 3.5.0-dev.1238

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/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @mentra/cloud-protocol
2
2
 
3
3
  The MentraOS phone ↔ cloud wire protocol: message schemas, envelopes, and
4
- error types shared by everything that speaks to the MentraOS cloud. Pure
5
- TypeScript + [zod](https://github.com/colinhacks/zod) — no server or native
6
- dependencies.
4
+ error types shared by everything that speaks to the MentraOS cloud, and the
5
+ miniapp URL rules Core and the engine both apply. Pure TypeScript +
6
+ [zod](https://github.com/colinhacks/zod), and [tldts](https://github.com/remusao/tldts)
7
+ for the Public Suffix List — no server or native dependencies.
7
8
 
8
9
  This is a **leaf package**: [`@mentra/cloud-client`](https://www.npmjs.com/package/@mentra/cloud-client)
9
10
  and [`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine) depend on
@@ -33,7 +34,8 @@ import {normalizePhotoSizeTier} from "@mentra/cloud-protocol/camera";
33
34
  ```
34
35
 
35
36
  Modules: `envelope`, `messages`, `handshake`, `control`, `camera`, `audio`,
36
- `maps`, `errors` — each importable as `@mentra/cloud-protocol/<module>`.
37
+ `maps`, `errors`, `miniapp-url` — each importable as
38
+ `@mentra/cloud-protocol/<module>`.
37
39
 
38
40
  The package ships TypeScript source (`main: ./src/index.ts`) and targets
39
41
  consumers that compile TS themselves (Metro / bundlers / tsc); it is not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/cloud-protocol",
3
- "version": "3.4.0-dev.998",
3
+ "version": "3.5.0-dev.1238",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "exports": {
@@ -15,6 +15,7 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
+ "tldts": "7.4.19",
18
19
  "zod": "^3.24.1"
19
20
  },
20
21
  "publishConfig": {
@@ -0,0 +1,194 @@
1
+ {
2
+ "$comment": "Shared by the protocol tests (miniapp-url.test.ts) and the engine tests, so Core and the Mentra App answer every case the same way. `null` means refused.",
3
+ "normalize": [
4
+ {"input": "https://notes.example.com", "expected": {"url": "https://notes.example.com/", "host": "notes.example.com", "kind": "public"}},
5
+ {"input": "https://notes.example.com/app", "expected": {"url": "https://notes.example.com/app/", "host": "notes.example.com", "kind": "public"}},
6
+ {"input": "https://notes.example.com/app/", "expected": {"url": "https://notes.example.com/app/", "host": "notes.example.com", "kind": "public"}},
7
+ {"input": "HTTPS://Notes.Example.COM/App", "expected": {"url": "https://notes.example.com/App/", "host": "notes.example.com", "kind": "public"}, "why": "the scheme and host are lowercased; the path keeps its case"},
8
+ {"input": "https://notes.example.com:443/app", "expected": {"url": "https://notes.example.com/app/", "host": "notes.example.com", "kind": "public"}, "why": "the default port is dropped"},
9
+ {"input": "https://notes.example.com:8443/app", "expected": {"url": "https://notes.example.com:8443/app/", "host": "notes.example.com", "kind": "public"}},
10
+ {"input": "https://notes.example.com./app", "expected": {"url": "https://notes.example.com/app/", "host": "notes.example.com", "kind": "public"}, "why": "a trailing dot names the same host"},
11
+ {"input": " https://notes.example.com/app ", "expected": {"url": "https://notes.example.com/app/", "host": "notes.example.com", "kind": "public"}},
12
+ {"input": "https://notes.example.com/a/./b/../c", "expected": {"url": "https://notes.example.com/a/c/", "host": "notes.example.com", "kind": "public"}},
13
+ {"input": "https://alice.github.io/notes", "expected": {"url": "https://alice.github.io/notes/", "host": "alice.github.io", "kind": "public"}},
14
+ {"input": "https://tools.acme.co.uk", "expected": {"url": "https://tools.acme.co.uk/", "host": "tools.acme.co.uk", "kind": "public"}},
15
+ {"input": "https://miniapps.mentraglass.com/com.mentra.notes", "expected": {"url": "https://miniapps.mentraglass.com/com.mentra.notes/", "host": "miniapps.mentraglass.com", "kind": "public"}},
16
+ {"input": "https://notes.my-site.com", "expected": {"url": "https://notes.my-site.com/", "host": "notes.my-site.com", "kind": "public"}},
17
+ {"input": "https://xn--mnchen-3ya.de/notes", "expected": {"url": "https://xn--mnchen-3ya.de/notes/", "host": "xn--mnchen-3ya.de", "kind": "public"}},
18
+ {"input": "https://93.184.215.14/notes", "expected": {"url": "https://93.184.215.14/notes/", "host": "93.184.215.14", "kind": "public"}, "why": "a public IP address is a valid URL, but no package name passes the domain rule on it"},
19
+ {"input": "http://localhost:8080", "expected": {"url": "http://localhost:8080/", "host": "localhost", "kind": "local"}},
20
+ {"input": "http://LOCALHOST:8080/app", "expected": {"url": "http://localhost:8080/app/", "host": "localhost", "kind": "local"}},
21
+ {"input": "http://localhost:80/", "expected": {"url": "http://localhost/", "host": "localhost", "kind": "local"}},
22
+ {"input": "http://10.0.0.5:3000/app", "expected": {"url": "http://10.0.0.5:3000/app/", "host": "10.0.0.5", "kind": "local"}},
23
+ {"input": "https://10.0.0.5/app", "expected": {"url": "https://10.0.0.5/app/", "host": "10.0.0.5", "kind": "local"}},
24
+ {"input": "http://127.1:8080", "expected": {"url": "http://127.0.0.1:8080/", "host": "127.0.0.1", "kind": "local"}, "why": "IPv4 shorthand is written out"},
25
+ {"input": "http://[fd00::1]:8080", "expected": {"url": "http://[fd00::1]:8080/", "host": "[fd00::1]", "kind": "local"}},
26
+ {"input": "http://[FD00:0:0:0:0:0:0:1]/", "expected": {"url": "http://[fd00::1]/", "host": "[fd00::1]", "kind": "local"}},
27
+ {"input": "http://[::ffff:10.0.0.5]/", "expected": {"url": "http://[::ffff:a00:5]/", "host": "[::ffff:a00:5]", "kind": "local"}},
28
+ {"input": "http://printer.local:8080/", "expected": {"url": "http://printer.local:8080/", "host": "printer.local", "kind": "local"}},
29
+ {"input": "http://dev.localhost:5173", "expected": {"url": "http://dev.localhost:5173/", "host": "dev.localhost", "kind": "local"}},
30
+ {"input": "http://notes.example.com/", "expected": null, "why": "HTTP is for local-network addresses only"},
31
+ {"input": "http://93.184.215.14/", "expected": null},
32
+ {"input": "https://user:secret@notes.example.com/", "expected": null, "why": "credentials"},
33
+ {"input": "https://user@notes.example.com/", "expected": null},
34
+ {"input": "https://@notes.example.com/", "expected": null},
35
+ {"input": "https://notes.example.com/app?v=2", "expected": null, "why": "a query"},
36
+ {"input": "https://notes.example.com/app?", "expected": null},
37
+ {"input": "https://notes.example.com/app#top", "expected": null, "why": "a fragment"},
38
+ {"input": "https://notes.example.com/app#", "expected": null},
39
+ {"input": "https://münchen.de/", "expected": null, "why": "a host in Unicode; IDN hosts are written in their xn-- form"},
40
+ {"input": "https://notes.example.com/ä/", "expected": null, "why": "non-ASCII; paths are percent-encoded"},
41
+ {"input": "https://notes.example.com/a b/", "expected": null},
42
+ {"input": "https://notes.example.com\\app", "expected": null},
43
+ {"input": "https://notes..example.com/", "expected": null},
44
+ {"input": "https://notes_app.example.com/", "expected": {"url": "https://notes_app.example.com/", "host": "notes_app.example.com", "kind": "public"}},
45
+ {"input": "https://-notes.example.com/", "expected": null},
46
+ {"input": "https://notes.example.com:99999/", "expected": null},
47
+ {"input": "https:notes.example.com", "expected": null},
48
+ {"input": "//notes.example.com/", "expected": null},
49
+ {"input": "notes.example.com", "expected": null},
50
+ {"input": "ftp://notes.example.com/", "expected": null},
51
+ {"input": "javascript:alert(1)", "expected": null},
52
+ {"input": "https://", "expected": null},
53
+ {"input": "", "expected": null}
54
+ ],
55
+ "registrableDomain": [
56
+ {"host": "notes.example.com", "expected": "example.com"},
57
+ {"host": "example.com", "expected": "example.com"},
58
+ {"host": "alice.github.io", "expected": "alice.github.io"},
59
+ {"host": "notes.alice.github.io", "expected": "alice.github.io"},
60
+ {"host": "tools.acme.co.uk", "expected": "acme.co.uk"},
61
+ {"host": "miniapps.mentraglass.com", "expected": "mentraglass.com"},
62
+ {"host": "miniapps.dev.mentraglass.com", "expected": "mentraglass.com"},
63
+ {"host": "Notes.Example.COM", "expected": "example.com"},
64
+ {"host": "notes.example.com.", "expected": "example.com"},
65
+ {"host": "notes.my-site.com", "expected": "my-site.com"},
66
+ {"host": "xn--mnchen-3ya.de", "expected": "xn--mnchen-3ya.de"},
67
+ {"host": "shop.example.xn--p1ai", "expected": "example.xn--p1ai"},
68
+ {"host": "com", "expected": null, "why": "a public suffix is nobody's domain"},
69
+ {"host": "co.uk", "expected": null},
70
+ {"host": "github.io", "expected": null},
71
+ {"host": "93.184.215.14", "expected": null},
72
+ {"host": "[2001:db8::1]", "expected": null},
73
+ {"host": "localhost", "expected": null},
74
+ {"host": "dev.localhost", "expected": null},
75
+ {"host": "printer.local", "expected": null},
76
+ {"host": "10.0.0.5", "expected": null},
77
+ {"host": "fd00::1", "expected": null},
78
+ {"host": "notes.corp.internal", "expected": null, "why": "a suffix the Public Suffix List doesn't know is owned by nobody"},
79
+ {"host": "notes.acme.example", "expected": null},
80
+ {"host": "printer.home.arpa", "expected": null},
81
+ {"host": "notes..example.com", "expected": null},
82
+ {"host": "notes.example.com:443", "expected": null, "why": "a host, not a URL or host and port"},
83
+ {"host": "https://notes.example.com/", "expected": null},
84
+ {"host": "user@notes.example.com", "expected": null},
85
+ {"host": "", "expected": null}
86
+ ],
87
+ "packagePrefix": [
88
+ {"host": "notes.example.com", "expected": "com.example"},
89
+ {"host": "alice.github.io", "expected": "io.github.alice"},
90
+ {"host": "tools.acme.co.uk", "expected": "uk.co.acme"},
91
+ {"host": "miniapps.mentraglass.com", "expected": "com.mentraglass"},
92
+ {"host": "NOTES.Example.COM.", "expected": "com.example"},
93
+ {"host": "notes.my-site.com", "expected": "com.my_site"},
94
+ {"host": "a--b.com", "expected": "com.a__b"},
95
+ {"host": "xn--mnchen-3ya.de", "expected": "de.xn__mnchen_3ya"},
96
+ {"host": "shop.example.xn--p1ai", "expected": "xn__p1ai.example"},
97
+ {"host": "app.1password.com", "expected": null, "why": "a label that starts with a digit can't start a package part"},
98
+ {"host": "my_site.com", "expected": null, "why": "only a hyphen becomes an underscore, so an underscore in the domain could collide with it"},
99
+ {"host": "93.184.215.14", "expected": null},
100
+ {"host": "localhost", "expected": null},
101
+ {"host": "github.io", "expected": null}
102
+ ],
103
+ "localNetwork": [
104
+ {"host": "localhost", "expected": true},
105
+ {"host": "LOCALHOST", "expected": true},
106
+ {"host": "localhost.", "expected": true},
107
+ {"host": "dev.localhost", "expected": true},
108
+ {"host": "printer.local", "expected": true},
109
+ {"host": "alexs-macbook.local", "expected": true},
110
+ {"host": "10.0.0.5", "expected": true},
111
+ {"host": "10.255.255.255", "expected": true},
112
+ {"host": "127.0.0.1", "expected": true},
113
+ {"host": "127.1", "expected": true},
114
+ {"host": "172.16.0.1", "expected": true},
115
+ {"host": "172.31.255.255", "expected": true},
116
+ {"host": "192.168.1.20", "expected": true},
117
+ {"host": "169.254.10.1", "expected": true},
118
+ {"host": "100.64.0.1", "expected": true, "why": "shared address space, used by overlay networks"},
119
+ {"host": "100.127.255.255", "expected": true},
120
+ {"host": "fd00::1", "expected": true},
121
+ {"host": "[fd00::1]", "expected": true},
122
+ {"host": "fc00::1", "expected": true},
123
+ {"host": "fe80::1", "expected": true},
124
+ {"host": "::1", "expected": true},
125
+ {"host": "[::1]", "expected": true},
126
+ {"host": "::ffff:10.0.0.5", "expected": true},
127
+ {"host": "[::ffff:a00:5]", "expected": true},
128
+ {"host": "local", "expected": false},
129
+ {"host": "notes.example.com", "expected": false},
130
+ {"host": "printer.local.example.com", "expected": false},
131
+ {"host": "localhost.example.com", "expected": false},
132
+ {"host": "172.15.255.255", "expected": false},
133
+ {"host": "172.32.0.1", "expected": false},
134
+ {"host": "192.169.0.1", "expected": false},
135
+ {"host": "100.63.255.255", "expected": false},
136
+ {"host": "100.128.0.1", "expected": false},
137
+ {"host": "11.0.0.1", "expected": false},
138
+ {"host": "8.8.8.8", "expected": false},
139
+ {"host": "::ffff:8.8.8.8", "expected": false},
140
+ {"host": "2001:db8::1", "expected": false},
141
+ {"host": "::", "expected": false},
142
+ {"host": "10.0.0.5:8080", "expected": false},
143
+ {"host": "http://10.0.0.5/", "expected": false},
144
+ {"host": "", "expected": false}
145
+ ],
146
+ "domainRule": [
147
+ {"packageName": "com.example.notes", "url": "https://notes.example.com/", "allowed": true},
148
+ {"packageName": "com.example.notes.pro", "url": "https://notes.example.com/app/", "allowed": true},
149
+ {"packageName": "com.example.notes", "url": "https://example.com/", "allowed": true},
150
+ {"packageName": "com.example", "url": "https://notes.example.com/", "allowed": false, "why": "the reversed domain needs at least one more part"},
151
+ {"packageName": "com.other.notes", "url": "https://notes.example.com/", "allowed": false},
152
+ {"packageName": "com.exampl.notes", "url": "https://notes.example.com/", "allowed": false},
153
+ {"packageName": "com.example.notes", "url": "https://notes.example.com.evil.net/", "allowed": false},
154
+ {"packageName": "com.example.notes", "url": "https://notes.examplex.com/", "allowed": false},
155
+ {"packageName": "io.github.alice.notes", "url": "https://alice.github.io/notes/", "allowed": true},
156
+ {"packageName": "io.github.bob.notes", "url": "https://alice.github.io/notes/", "allowed": false, "why": "each GitHub Pages user has their own namespace"},
157
+ {"packageName": "io.github.notes", "url": "https://alice.github.io/", "allowed": false},
158
+ {"packageName": "uk.co.acme.tools", "url": "https://tools.acme.co.uk/", "allowed": true},
159
+ {"packageName": "uk.co.tools", "url": "https://tools.acme.co.uk/", "allowed": false},
160
+ {"packageName": "com.mentra.notes", "url": "https://miniapps.mentraglass.com/com.mentra.notes/", "allowed": true, "why": "com.mentra.* belongs to mentraglass.com"},
161
+ {"packageName": "com.mentra.notes", "url": "https://miniapps.dev.mentraglass.com/com.mentra.notes/", "allowed": true},
162
+ {"packageName": "com.mentraglass.tools", "url": "https://mentraglass.com/tools/", "allowed": true},
163
+ {"packageName": "com.mentra.notes", "url": "https://notes.example.com/", "allowed": false},
164
+ {"packageName": "com.mentra.notes", "url": "https://mentra.com/notes/", "allowed": false, "why": "Mentra owns mentraglass.com, not mentra.com"},
165
+ {"packageName": "com.mentra.notes", "url": "https://mentraglass.com.evil.example/", "allowed": false},
166
+ {"packageName": "com.mentra.notes", "url": "https://notmentraglass.com/", "allowed": false},
167
+ {"packageName": "com.mentra.notes", "url": "http://miniapps.mentraglass.com/com.mentra.notes/", "allowed": false},
168
+ {"packageName": "com.mentra", "url": "https://mentraglass.com/", "allowed": false},
169
+ {"packageName": "com.mentrax.notes", "url": "https://notes.mentrax.com/", "allowed": true},
170
+ {"packageName": "com.my_site.notes", "url": "https://notes.my-site.com/", "allowed": true, "why": "a hyphen in a domain label is an underscore in the package name"},
171
+ {"packageName": "com.my-site.notes", "url": "https://notes.my-site.com/", "allowed": false, "why": "package names have no hyphens"},
172
+ {"packageName": "com.mysite.notes", "url": "https://notes.my-site.com/", "allowed": false},
173
+ {"packageName": "de.xn__mnchen_3ya.notes", "url": "https://xn--mnchen-3ya.de/notes/", "allowed": true},
174
+ {"packageName": "de.xn__mnchen_3ya.notes", "url": "https://münchen.de/notes/", "allowed": false},
175
+ {"packageName": "com.example.notes", "url": "https://NOTES.Example.COM./app/", "allowed": true},
176
+ {"packageName": "com.Example.notes", "url": "https://notes.example.com/", "allowed": false, "why": "package names are lowercase"},
177
+ {"packageName": "com.example.Notes", "url": "https://notes.example.com/", "allowed": false},
178
+ {"packageName": "com.example.1notes", "url": "https://notes.example.com/", "allowed": false},
179
+ {"packageName": "com.example.", "url": "https://notes.example.com/", "allowed": false},
180
+ {"packageName": "com.1password.notes", "url": "https://app.1password.com/", "allowed": false},
181
+ {"packageName": "com.example.notes", "url": "https://93.184.215.14/", "allowed": false, "why": "an IP address has no registrable domain"},
182
+ {"packageName": "com.example.notes", "url": "http://localhost:8080/", "allowed": false, "why": "a local-network address has no domain to own the name"},
183
+ {"packageName": "com.example.notes", "url": "https://10.0.0.5/", "allowed": false},
184
+ {"packageName": "com.example.notes", "url": "https://[fd00::1]/", "allowed": false},
185
+ {"packageName": "local.printer.notes", "url": "https://printer.local/", "allowed": false},
186
+ {"packageName": "internal.corp.notes", "url": "https://notes.corp.internal/", "allowed": false},
187
+ {"packageName": "arpa.home.notes", "url": "https://notes.home.arpa/", "allowed": false},
188
+ {"packageName": "com.example.notes", "url": "https://user:secret@notes.example.com/", "allowed": false},
189
+ {"packageName": "com.example.notes", "url": "https://notes.example.com/miniapp.json", "allowed": true, "why": "only the host decides; the path doesn't"},
190
+ {"packageName": "com.example.notes", "url": "https://cdn.example.com/notes/miniapp.json?sig=abc", "allowed": true, "why": "a query on a redirect target doesn't change its host"},
191
+ {"packageName": "com.example.notes", "url": "notes.example.com", "allowed": false},
192
+ {"packageName": "com.example.notes", "url": "", "allowed": false}
193
+ ]
194
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @fileoverview A person's declared age, kept on their Core account: `/api/account/declared-age`.
3
+ *
4
+ * A miniapp that declares an `ageRating` above the host app's own App Store rating installs or opens only for a person
5
+ * whose declared age is old enough (App Store Review Guideline 4.7.5). The first time that comes up, the app asks the
6
+ * person's age once, and Core keeps it on their account with the date they gave it. Their age now is the declared age
7
+ * plus the whole years since that date ({@link currentDeclaredAge}). Core never puts it in a token or sends it to a
8
+ * miniapp.
9
+ *
10
+ * GET /api/account/declared-age {declaredAge, declaredAgeAt}, both null until the person answers
11
+ * PUT /api/account/declared-age {age} {declaredAge, declaredAgeAt}
12
+ *
13
+ * `GET /api/account/me` carries the same two fields.
14
+ */
15
+ import {z} from "zod"
16
+
17
+ /** The ages a person may give: a whole number of years. */
18
+ export const DECLARED_AGE_MIN = 1
19
+ export const DECLARED_AGE_MAX = 120
20
+
21
+ export const DeclaredAgeRequestSchema = z.object({
22
+ age: z.number().int().min(DECLARED_AGE_MIN).max(DECLARED_AGE_MAX),
23
+ })
24
+ export type DeclaredAgeRequest = z.infer<typeof DeclaredAgeRequestSchema>
25
+
26
+ /** What Core keeps. Both fields are null until the person answers, and set together after. */
27
+ export interface DeclaredAge {
28
+ /** The age the person gave. */
29
+ declaredAge: number | null
30
+ /** When they gave it, ISO 8601. */
31
+ declaredAgeAt: string | null
32
+ }
33
+
34
+ export const DeclaredAgeSchema: z.ZodType<DeclaredAge> = z.union([
35
+ z.object({
36
+ declaredAge: z.number().int().min(DECLARED_AGE_MIN).max(DECLARED_AGE_MAX),
37
+ declaredAgeAt: z.string().datetime({offset: true}),
38
+ }),
39
+ z.object({declaredAge: z.null(), declaredAgeAt: z.null()}),
40
+ ])
41
+
42
+ /**
43
+ * The person's age at `now`: the age they gave plus the whole years since they gave it, counted by calendar date in
44
+ * UTC. Never less than the age they gave, even when `now` is earlier.
45
+ */
46
+ export function currentDeclaredAge(declaredAge: number, declaredAgeAt: string | Date, now: Date): number {
47
+ const at = new Date(declaredAgeAt)
48
+ if (Number.isNaN(at.getTime()) || Number.isNaN(now.getTime())) return declaredAge
49
+ let years = now.getUTCFullYear() - at.getUTCFullYear()
50
+ const beforeAnniversary =
51
+ now.getUTCMonth() < at.getUTCMonth() ||
52
+ (now.getUTCMonth() === at.getUTCMonth() && now.getUTCDate() < at.getUTCDate())
53
+ if (beforeAnniversary) years -= 1
54
+ return declaredAge + Math.max(0, years)
55
+ }
package/src/fleet.ts CHANGED
@@ -17,12 +17,32 @@ export const FLEET_LIMITS = {
17
17
  maxBatchBytes: 1_048_576,
18
18
  maxSessionsPerCheckpoint: 32,
19
19
  maxSessionElapsedMs: 7 * 24 * 60 * 60 * 1000,
20
+ maxInventoryMiniapps: 500,
21
+ maxAssignmentEntries: 500,
20
22
  phoneQueueMaxRecords: 10_000,
21
23
  phoneQueueMaxAgeMs: 7 * 24 * 60 * 60 * 1000,
22
24
  replayWindowMs: 14 * 24 * 60 * 60 * 1000,
23
25
  maxFutureSkewMs: 5 * 60 * 1000,
26
+ maxTelemetryLossPackages: 500,
27
+ maxCrashesInWindow: 1_000,
28
+ maxReconnectMs: 7 * 24 * 60 * 60 * 1000,
24
29
  } as const
25
30
 
31
+ /**
32
+ * Phase 12: a miniapp's own events. The Mentra App applies these before it queues an event (dropping the event, a
33
+ * property, or cutting a string) and MFM checks them again, rejecting a record that still breaks one.
34
+ */
35
+ export const FLEET_TELEMETRY_LIMITS = {
36
+ maxProperties: 25,
37
+ maxStringValueLength: 64,
38
+ eventsPerMinute: 60,
39
+ eventsPerDay: 2_000,
40
+ /** Places of the phone's queue telemetry may hold; when full, the oldest telemetry record gives way. */
41
+ queueShare: 2_000,
42
+ } as const
43
+ /** An event or property name: 1-64 lowercase letters, digits and underscores, starting with a letter. */
44
+ export const FLEET_TELEMETRY_NAME = /^[a-z][a-z0-9_]{0,63}$/
45
+
26
46
  const FleetId = z
27
47
  .string()
28
48
  .min(1)
@@ -60,6 +80,11 @@ export const FLEET_CAPABILITIES = [
60
80
  "app_foreground",
61
81
  "engine_active",
62
82
  "photo_operations",
83
+ "phone_battery",
84
+ // Phase 12. A record kind the phone can produce is declared, so a view tells a measured 0 from an unknown.
85
+ "miniapp_failures",
86
+ "glasses_connectivity",
87
+ "miniapp_telemetry",
63
88
  ] as const
64
89
 
65
90
  const Battery = z.discriminatedUnion("state", [
@@ -86,6 +111,8 @@ export const FleetSnapshotPayloadSchema = z
86
111
  appBuild: FleetText,
87
112
  engineVersion: FleetText.optional(),
88
113
  sdkVersion: FleetText.optional(),
114
+ /** The phone's own battery; absent from an app that does not report one. */
115
+ battery: Battery.optional(),
89
116
  })
90
117
  .strict(),
91
118
  glasses: z
@@ -110,6 +137,18 @@ export const FleetSnapshotPayloadSchema = z
110
137
  })
111
138
  .strict()
112
139
 
140
+ /** Phase 12: why a launch failed. Codes only, never error text. */
141
+ export const FLEET_LAUNCH_FAILURE_REASONS = [
142
+ "bundle_missing",
143
+ "incompatible_host",
144
+ "policy_blocked",
145
+ "spawn_failed",
146
+ "connect_timeout",
147
+ "permission_denied",
148
+ "unknown",
149
+ ] as const
150
+ export type FleetLaunchFailureReason = (typeof FLEET_LAUNCH_FAILURE_REASONS)[number]
151
+
113
152
  export const FleetLaunchPayloadSchema = z
114
153
  .object({
115
154
  packageName: FleetText,
@@ -117,8 +156,14 @@ export const FleetLaunchPayloadSchema = z
117
156
  versionName: FleetText.optional(),
118
157
  start: z.enum(["user", "automatic"]),
119
158
  outcome: z.enum(["started", "failed"]),
159
+ /** Phase 12. Only a failed launch carries it; a Mentra App from before Phase 12 sends none. */
160
+ failureReason: z.enum(FLEET_LAUNCH_FAILURE_REASONS).optional(),
120
161
  })
121
162
  .strict()
163
+ .refine((v) => v.failureReason === undefined || v.outcome === "failed", {
164
+ path: ["failureReason"],
165
+ message: "only a failed launch carries a failureReason",
166
+ })
122
167
 
123
168
  export const FLEET_ACTIVITIES = ["glasses_connection", "miniapp_execution", "app_foreground", "engine_active"] as const
124
169
  export const FleetActivityCheckpointSchema = z
@@ -154,6 +199,96 @@ export const FleetPhotoPayloadSchema = z
154
199
  .strict()
155
200
  .refine((v) => (v.stage === "failed") === (v.failedStage !== undefined), "failedStage goes with stage failed")
156
201
 
202
+ /** The provisioning spec's installation reasons. Management reasons are kept apart from the artifact's provenance. */
203
+ export const FLEET_INSTALL_REASONS = ["bundled", "explicit", "manifest", "assignment"] as const
204
+ export type FleetInstallReason = (typeof FLEET_INSTALL_REASONS)[number]
205
+
206
+ /** Phase 11: the phone's installed miniapps. Sent when the collector starts and whenever the inventory changes. */
207
+ export const FleetMiniappInventoryPayloadSchema = z
208
+ .object({
209
+ miniapps: z
210
+ .array(
211
+ z
212
+ .object({
213
+ packageName: FleetText,
214
+ versionName: FleetText,
215
+ digest: FleetText,
216
+ reasons: z.array(z.enum(FLEET_INSTALL_REASONS)).min(1).max(FLEET_INSTALL_REASONS.length),
217
+ })
218
+ .strict(),
219
+ )
220
+ .max(FLEET_LIMITS.maxInventoryMiniapps),
221
+ })
222
+ .strict()
223
+
224
+ /** The provisioning spec's outcome states for one assignment on one phone. */
225
+ export const FLEET_ASSIGNMENT_STATES = [
226
+ "pending",
227
+ "deferred_running",
228
+ "downloading",
229
+ "installing",
230
+ "installed",
231
+ "blocked",
232
+ "retrying",
233
+ "withdrawn",
234
+ ] as const
235
+ export type FleetAssignmentState = (typeof FLEET_ASSIGNMENT_STATES)[number]
236
+
237
+ /** Stable reasons for a blocked or retrying assignment. Never error text. */
238
+ export const FLEET_ASSIGNMENT_REASONS = [
239
+ "archived",
240
+ "access_denied",
241
+ "not_a_miniapp",
242
+ "download_failed",
243
+ "signature_invalid",
244
+ "incompatible_host",
245
+ "newer_version_installed",
246
+ "manifest_conflict",
247
+ "storage_full",
248
+ ] as const
249
+ export type FleetAssignmentReason = (typeof FLEET_ASSIGNMENT_REASONS)[number]
250
+
251
+ /** A miniapp's address: https, bounded, and with no credentials in it. */
252
+ export const FleetMiniappUrl = z
253
+ .string()
254
+ .min(1)
255
+ .max(2048)
256
+ .refine((value) => {
257
+ try {
258
+ const url = new URL(value)
259
+ return url.protocol === "https:" && url.username === "" && url.password === ""
260
+ } catch {
261
+ return false
262
+ }
263
+ }, "a miniapp URL is an https address without credentials")
264
+
265
+ const FleetRevision = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
266
+
267
+ /** Phase 11: one assignment's state on this phone. */
268
+ export const FleetAssignmentOutcomePayloadSchema = z
269
+ .object({
270
+ assignmentId: FleetId,
271
+ /** The assignment's own revision. */
272
+ assignmentRevision: FleetRevision,
273
+ /** The account's desired-state revision the phone acted on. */
274
+ desiredRevision: FleetRevision,
275
+ packageName: FleetText,
276
+ state: z.enum(FLEET_ASSIGNMENT_STATES),
277
+ reason: z.enum(FLEET_ASSIGNMENT_REASONS).optional(),
278
+ selectedVersion: FleetText.optional(),
279
+ installedVersion: FleetText.optional(),
280
+ attempts: z.number().int().min(0).max(1_000_000),
281
+ attemptedAt: FleetUtcTime,
282
+ lastSuccessAt: FleetUtcTime.optional(),
283
+ })
284
+ .strict()
285
+ .superRefine((value, ctx) => {
286
+ if (value.state === "blocked" && value.reason === undefined)
287
+ ctx.addIssue({code: "custom", path: ["reason"], message: "a blocked assignment carries its reason"})
288
+ if (value.state !== "blocked" && value.state !== "retrying" && value.reason !== undefined)
289
+ ctx.addIssue({code: "custom", path: ["reason"], message: "only a blocked or retrying assignment carries a reason"})
290
+ })
291
+
157
292
  export const FleetCoverageGapPayloadSchema = z
158
293
  .object({
159
294
  reason: z.enum(["queue_dropped", "queue_expired", "clock_untrusted", "reporter_stopped", "record_rejected"]),
@@ -163,6 +298,146 @@ export const FleetCoverageGapPayloadSchema = z
163
298
  })
164
299
  .strict()
165
300
 
301
+ const TelemetryName = z.string().regex(FLEET_TELEMETRY_NAME)
302
+ const TelemetryValue = z.union([
303
+ z.string().max(FLEET_TELEMETRY_LIMITS.maxStringValueLength),
304
+ z.number().finite(),
305
+ z.boolean(),
306
+ ])
307
+
308
+ /** Phase 12: one event a miniapp captured, attributed by the Mentra App to the miniapp whose bridge sent it. */
309
+ export const FleetTelemetryEventPayloadSchema = z
310
+ .object({
311
+ packageName: FleetText,
312
+ versionName: FleetText.optional(),
313
+ /** The miniapp's execution session at capture (its `miniapp_execution` activitySessionId), when one was open. */
314
+ executionSessionId: FleetId.optional(),
315
+ event: TelemetryName,
316
+ properties: z
317
+ .record(TelemetryName, TelemetryValue)
318
+ .refine((p) => Object.keys(p).length <= FLEET_TELEMETRY_LIMITS.maxProperties, "too many properties"),
319
+ /** Properties the Mentra App dropped from this event (bad name or value, or past the 25th). */
320
+ droppedProperties: z.number().int().min(0).max(1_000_000),
321
+ /** String values the Mentra App cut to 64 characters. */
322
+ cutValues: z.number().int().min(0).max(FLEET_TELEMETRY_LIMITS.maxProperties),
323
+ })
324
+ .strict()
325
+
326
+ /** What the Mentra App lost of one miniapp's telemetry. */
327
+ export const FleetTelemetryLossSchema = z
328
+ .object({
329
+ packageName: FleetText,
330
+ /** Over the name, rate or daily limit. */
331
+ overLimit: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
332
+ /** Given way in the queue to newer telemetry. */
333
+ evicted: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
334
+ /** Too old in the queue to send. */
335
+ expired: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
336
+ /** Sent, and rejected by MFM. */
337
+ rejected: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
338
+ })
339
+ .strict()
340
+ .refine((v) => v.overLimit + v.evicted + v.expired + v.rejected > 0, "a loss counts something")
341
+
342
+ /** Phase 12: telemetry the Mentra App lost over a period, per miniapp. Never a coverage gap. */
343
+ export const FleetTelemetryLossPayloadSchema = z
344
+ .object({
345
+ from: FleetUtcTime,
346
+ to: FleetUtcTime,
347
+ losses: z.array(FleetTelemetryLossSchema).min(1).max(FLEET_LIMITS.maxTelemetryLossPackages),
348
+ })
349
+ .strict()
350
+ .refine((v) => Date.parse(v.from) <= Date.parse(v.to), {path: ["to"], message: "to is not before from"})
351
+
352
+ export const FLEET_CRASH_SURFACES = ["context", "webview"] as const
353
+ export const FLEET_CRASH_CATEGORIES = [
354
+ "uncaught_exception",
355
+ "bridge_error",
356
+ "unresponsive",
357
+ "watchdog_kill",
358
+ "content_process_terminated",
359
+ "unknown",
360
+ ] as const
361
+ export type FleetCrashCategory = (typeof FLEET_CRASH_CATEGORIES)[number]
362
+ /**
363
+ * What followed a crash: the engine restarts the miniapp, or stops restarting it after repeated crashes, or (a screen
364
+ * whose content process died) the screen closes and nothing restarts it until the person opens it again.
365
+ */
366
+ export const FLEET_CRASH_OUTCOMES = ["respawn_scheduled", "crash_loop_stopped", "closed"] as const
367
+
368
+ /** Phase 12: one miniapp crash. The engine maps its reason to a category; the reason text never leaves the phone. */
369
+ export const FleetMiniappCrashPayloadSchema = z
370
+ .object({
371
+ packageName: FleetText,
372
+ versionName: FleetText.optional(),
373
+ executionSessionId: FleetId.optional(),
374
+ surface: z.enum(FLEET_CRASH_SURFACES),
375
+ category: z.enum(FLEET_CRASH_CATEGORIES),
376
+ outcome: z.enum(FLEET_CRASH_OUTCOMES),
377
+ /** Crashes of this miniapp in the crash window, this one included. */
378
+ crashesInWindow: z.number().int().min(1).max(FLEET_LIMITS.maxCrashesInWindow),
379
+ })
380
+ .strict()
381
+
382
+ export const FLEET_INSTALL_OPERATIONS = ["install", "update", "remove"] as const
383
+ /** What started an install: `url` covers a link, the QR scanner, the paste box, the system list and the update check. */
384
+ export const FLEET_INSTALL_SOURCES = ["bundled", "url", "manifest", "local", "assignment"] as const
385
+ export const FLEET_INSTALL_OUTCOMES = ["succeeded", "failed", "deferred"] as const
386
+ /** `assignment_outcome`'s reasons where they apply, and three of its own. */
387
+ export const FLEET_INSTALL_FAILURE_REASONS = [...FLEET_ASSIGNMENT_REASONS, "network", "user_cancelled", "unknown"] as const
388
+ export type FleetInstallFailureReason = (typeof FLEET_INSTALL_FAILURE_REASONS)[number]
389
+
390
+ /** Phase 12: one install, update or removal attempt, managed or not. */
391
+ export const FleetMiniappInstallPayloadSchema = z
392
+ .object({
393
+ packageName: FleetText,
394
+ operation: z.enum(FLEET_INSTALL_OPERATIONS),
395
+ source: z.enum(FLEET_INSTALL_SOURCES),
396
+ fromVersion: FleetText.optional(),
397
+ toVersion: FleetText.optional(),
398
+ outcome: z.enum(FLEET_INSTALL_OUTCOMES),
399
+ reason: z.enum(FLEET_INSTALL_FAILURE_REASONS).optional(),
400
+ })
401
+ .strict()
402
+ .refine((v) => (v.outcome === "succeeded") === (v.reason === undefined), {
403
+ path: ["reason"],
404
+ message: "a failed or deferred attempt carries its reason, and only it",
405
+ })
406
+
407
+ export const FLEET_CONNECTION_EVENTS = ["pair_failed", "connect_failed", "disconnected", "reconnected"] as const
408
+ export type FleetConnectionEventKind = (typeof FLEET_CONNECTION_EVENTS)[number]
409
+ export const FLEET_CONNECTION_CATEGORIES = [
410
+ "bluetooth_off",
411
+ "permission_denied",
412
+ "bond_failed",
413
+ "not_found",
414
+ "timeout",
415
+ "firmware_incompatible",
416
+ "link_lost",
417
+ "user_action",
418
+ "unknown",
419
+ ] as const
420
+ export type FleetConnectionCategory = (typeof FLEET_CONNECTION_CATEGORIES)[number]
421
+
422
+ /**
423
+ * Phase 12: a pairing or connection change. A disconnect the person asked for is `user_action`, and is not shown as a
424
+ * problem. The glasses are the record's `deviceBinding` when the phone knows them.
425
+ */
426
+ export const FleetConnectionEventPayloadSchema = z
427
+ .object({
428
+ event: z.enum(FLEET_CONNECTION_EVENTS),
429
+ category: z.enum(FLEET_CONNECTION_CATEGORIES),
430
+ glassesModel: FleetText.optional(),
431
+ appVersion: FleetText,
432
+ /** How long the reconnect took, for `reconnected` only. */
433
+ reconnectMs: z.number().int().min(0).max(FLEET_LIMITS.maxReconnectMs).optional(),
434
+ })
435
+ .strict()
436
+ .refine((v) => (v.event === "reconnected") === (v.reconnectMs !== undefined), {
437
+ path: ["reconnectMs"],
438
+ message: "a reconnect carries the time it took, and only it",
439
+ })
440
+
166
441
  const base = {
167
442
  schemaVersion: z.literal(FLEET_SCHEMA_VERSION),
168
443
  recordId: FleetId,
@@ -170,10 +445,6 @@ const base = {
170
445
  collectorSessionId: FleetId,
171
446
  sequence: z.number().int().min(0),
172
447
  observedAt: FleetUtcTime,
173
- management: z
174
- .object({associationId: FleetId, generation: z.number().int().min(0)})
175
- .strict()
176
- .optional(),
177
448
  capabilities: z.array(z.enum(FLEET_CAPABILITIES)).max(FLEET_CAPABILITIES.length),
178
449
  }
179
450
  export const FleetRecordSchema = z.discriminatedUnion("kind", [
@@ -197,6 +468,20 @@ export const FleetRecordSchema = z.discriminatedUnion("kind", [
197
468
  })
198
469
  .strict(),
199
470
  z.object({...base, kind: z.literal("coverage_gap"), payload: FleetCoverageGapPayloadSchema}).strict(),
471
+ z.object({...base, kind: z.literal("miniapp_inventory"), payload: FleetMiniappInventoryPayloadSchema}).strict(),
472
+ z.object({...base, kind: z.literal("assignment_outcome"), payload: FleetAssignmentOutcomePayloadSchema}).strict(),
473
+ z.object({...base, kind: z.literal("telemetry_event"), payload: FleetTelemetryEventPayloadSchema}).strict(),
474
+ z.object({...base, kind: z.literal("telemetry_loss"), payload: FleetTelemetryLossPayloadSchema}).strict(),
475
+ z.object({...base, kind: z.literal("miniapp_crash"), payload: FleetMiniappCrashPayloadSchema}).strict(),
476
+ z.object({...base, kind: z.literal("miniapp_install"), payload: FleetMiniappInstallPayloadSchema}).strict(),
477
+ z
478
+ .object({
479
+ ...base,
480
+ kind: z.literal("connection_event"),
481
+ deviceBinding: FleetDeviceBindingSchema.optional(),
482
+ payload: FleetConnectionEventPayloadSchema,
483
+ })
484
+ .strict(),
200
485
  ]).superRefine((value, ctx) => {
201
486
  // A snapshot's glasses belong to the pair its binding names, so the two come together.
202
487
  if (value.kind === "snapshot" && (value.payload.glasses !== undefined) !== (value.deviceBinding !== undefined))
@@ -243,11 +528,63 @@ export const FleetReportAckSchema = z
243
528
  .object({
244
529
  schemaVersion: z.literal(FLEET_SCHEMA_VERSION),
245
530
  results: z.array(FleetRecordResultSchema),
531
+ /**
532
+ * The account's current desired-state revision (Phase 11). A phone holding an older one fetches desired state at
533
+ * once; there is no push channel. Optional so an acknowledgment from an older MFM still parses.
534
+ */
535
+ revision: FleetRevision.optional(),
246
536
  })
247
537
  .strict()
248
538
  export type FleetRecordResult = z.infer<typeof FleetRecordResultSchema>
249
539
  export type FleetReportAck = z.infer<typeof FleetReportAckSchema>
250
540
 
541
+ /**
542
+ * Desired state, as MFM answers a phone through Core (`GET /api/client/fleet/desired-state`). Every answer carries one
543
+ * monotonic per-account `revision` and its generation time.
544
+ *
545
+ * - `managed`: the account's workspace and the assignment entries, which may be empty. A member of a workspace with
546
+ * nothing assigned is still managed.
547
+ * - `unmanaged`: the account belongs to no workspace. Authoritative: the phone clears its assignment reasons.
548
+ *
549
+ * An error, timeout, partial response or missing MFM is never an empty set: MFM answers {@link FLEET_UNAVAILABLE}
550
+ * (503) and the phone keeps its last complete snapshot. `paused` (Phase 15) is not part of this schema, so a phone
551
+ * that receives an unknown status keeps its last snapshot.
552
+ */
553
+ export const FleetDesiredStateEntrySchema = z
554
+ .object({
555
+ assignmentId: FleetId,
556
+ revision: FleetRevision,
557
+ /** The miniapp's own URL (Mentra-Specs #140). The phone installs it with `installMiniapp(url)` and takes the version it serves. */
558
+ url: FleetMiniappUrl,
559
+ /** The package the URL's manifest named when it was assigned. Display and matching only; the URL decides what installs. */
560
+ packageName: FleetText,
561
+ selection: z.literal("latest"),
562
+ })
563
+ .strict()
564
+
565
+ export const FleetDesiredStateSchema = z.discriminatedUnion("status", [
566
+ z
567
+ .object({
568
+ status: z.literal("managed"),
569
+ revision: FleetRevision,
570
+ generatedAt: FleetUtcTime,
571
+ workspace: z.object({workspaceId: FleetId, name: z.string().min(1).max(200)}).strict(),
572
+ entries: z.array(FleetDesiredStateEntrySchema).max(FLEET_LIMITS.maxAssignmentEntries),
573
+ })
574
+ .strict(),
575
+ z.object({status: z.literal("unmanaged"), revision: FleetRevision, generatedAt: FleetUtcTime}).strict(),
576
+ ])
577
+ export type FleetDesiredStateEntry = z.infer<typeof FleetDesiredStateEntrySchema>
578
+ export type FleetDesiredState = z.infer<typeof FleetDesiredStateSchema>
579
+ export type FleetMiniappInventoryPayload = z.infer<typeof FleetMiniappInventoryPayloadSchema>
580
+ export type FleetAssignmentOutcomePayload = z.infer<typeof FleetAssignmentOutcomePayloadSchema>
581
+ export type FleetLaunchPayload = z.infer<typeof FleetLaunchPayloadSchema>
582
+ export type FleetTelemetryEventPayload = z.infer<typeof FleetTelemetryEventPayloadSchema>
583
+ export type FleetTelemetryLossPayload = z.infer<typeof FleetTelemetryLossPayloadSchema>
584
+ export type FleetMiniappCrashPayload = z.infer<typeof FleetMiniappCrashPayloadSchema>
585
+ export type FleetMiniappInstallPayload = z.infer<typeof FleetMiniappInstallPayloadSchema>
586
+ export type FleetConnectionEventPayload = z.infer<typeof FleetConnectionEventPayloadSchema>
587
+
251
588
  /** Whole-response error codes Core and MFM answer with. */
252
589
  export const FLEET_NOT_INSTALLED = "fleet_not_installed"
253
590
  export const FLEET_UNAVAILABLE = "fleet_unavailable"
package/src/index.ts CHANGED
@@ -18,3 +18,6 @@ export * from "./camera";
18
18
  export * from "./maps";
19
19
  export * from "./report-automation";
20
20
  export * from "./fleet";
21
+ export * from "./miniapp-url";
22
+ export * from "./miniapp-registry";
23
+ export * from "./declared-age";
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @fileoverview The Mentra App's registry calls to Core: `/api/client/registry/register` and `/status`.
3
+ *
4
+ * Every install of a miniapp URL registers it. Core verifies the URL itself, with its own fetcher, and answers:
5
+ * - `accepted`, with the `miniapp.json` Core read;
6
+ * - `accepted_unverified`, when Core could not read it: the URL is reachable only from the phone, or its server asks
7
+ * for sign-in and refused the person's token. The phone's own request decides whether it installs;
8
+ * - `not_a_miniapp`, which stops the install;
9
+ * - `archived`, which stops it too.
10
+ *
11
+ * The updater sends one status batch for every installed URL and keeps the answer for `validForSeconds`. An archived
12
+ * URL is never updated; installed copies keep running.
13
+ *
14
+ * Core validates requests with these schemas, and the Cloud Client validates Core's answers with them. Objects keep
15
+ * only the fields they name, so a later Core can add fields without breaking an older phone.
16
+ */
17
+ import {z} from "zod"
18
+ import {
19
+ MINIAPP_AGE_RATINGS,
20
+ MINIAPP_PACKAGE_NAME_PATTERN,
21
+ MINIAPP_VERSION_PATTERN,
22
+ ServedMiniappManifestSchema,
23
+ type MiniappAgeRating,
24
+ type ServedMiniappManifest,
25
+ } from "./miniapp-url"
26
+
27
+ /** The most URLs one status call may name. */
28
+ export const MINIAPP_REGISTRY_STATUS_MAX_ENTRIES = 500
29
+ /** How long the phone keeps a status answer when Core doesn't say: 6 hours. */
30
+ export const MINIAPP_REGISTRY_STATUS_DEFAULT_TTL_SECONDS = 21_600
31
+
32
+ const MAX_URL_LENGTH = 2048
33
+ const MAX_NAME_LENGTH = 500
34
+ const MAX_VERSION_LENGTH = 256
35
+
36
+ /** `verified`: Core read the URL's `miniapp.json` itself. `phone_reported`: Core could not, so it holds the phone's report. */
37
+ export type MiniappRegistryVerification = "verified" | "phone_reported"
38
+ /** An entry's own status. An archive rule can also cover an `active` entry; the status call answers for both. */
39
+ export type MiniappRegistryEntryStatus = "active" | "archived"
40
+
41
+ /** One registry entry, as Core shows it to a caller. */
42
+ export interface RegistryEntryView {
43
+ /** The normalized miniapp URL: the entry's key. */
44
+ url: string
45
+ packageName: string
46
+ registrableDomain: string
47
+ /**
48
+ * Null when Core knows only the package name: a private miniapp whose server refused Core's token, or a private
49
+ * entry whose server hasn't admitted this caller.
50
+ */
51
+ name: string | null
52
+ version: string | null
53
+ ageRating: MiniappAgeRating | null
54
+ iconUrl: string | null
55
+ verification: MiniappRegistryVerification
56
+ /** Its server asks for sign-in. */
57
+ private: boolean
58
+ status: MiniappRegistryEntryStatus
59
+ /** ISO 8601. */
60
+ firstRegisteredAt: string
61
+ /** ISO 8601, or null when Core has never read it. */
62
+ lastVerifiedAt: string | null
63
+ }
64
+
65
+ /** Why a URL isn't a miniapp. */
66
+ export const MINIAPP_REGISTRY_NOT_A_MINIAPP_REASONS = [
67
+ "invalid_manifest",
68
+ "domain_mismatch",
69
+ "not_https",
70
+ "local_network_url",
71
+ ] as const
72
+ export type MiniappRegistryNotAMiniappReason = (typeof MINIAPP_REGISTRY_NOT_A_MINIAPP_REASONS)[number]
73
+
74
+ /** What the phone read from the URL, sent with every registration. Core keeps it only when it can't read the URL. */
75
+ export const RegistryReportedManifestSchema = z.object({
76
+ packageName: z.string().regex(MINIAPP_PACKAGE_NAME_PATTERN),
77
+ name: z
78
+ .string()
79
+ .max(MAX_NAME_LENGTH)
80
+ .refine((name) => name.trim().length > 0, "must not be blank"),
81
+ version: z.string().max(MAX_VERSION_LENGTH).regex(MINIAPP_VERSION_PATTERN),
82
+ /** Absent (or null) when the miniapp declares none. */
83
+ ageRating: z.enum(MINIAPP_AGE_RATINGS).nullish(),
84
+ })
85
+ export type RegistryReportedManifest = z.infer<typeof RegistryReportedManifestSchema>
86
+
87
+ export const RegistryRegisterRequestSchema = z.object({
88
+ url: z.string().min(1).max(MAX_URL_LENGTH),
89
+ reported: RegistryReportedManifestSchema.optional(),
90
+ })
91
+ export type RegistryRegisterRequest = z.infer<typeof RegistryRegisterRequestSchema>
92
+
93
+ const IsoTime = z.string().min(1)
94
+
95
+ export const RegistryEntryViewSchema: z.ZodType<RegistryEntryView> = z.object({
96
+ url: z.string().min(1),
97
+ packageName: z.string().min(1),
98
+ registrableDomain: z.string().min(1),
99
+ name: z.string().nullable(),
100
+ version: z.string().nullable(),
101
+ ageRating: z.enum(MINIAPP_AGE_RATINGS).nullable(),
102
+ iconUrl: z.string().nullable(),
103
+ verification: z.enum(["verified", "phone_reported"]),
104
+ private: z.boolean(),
105
+ status: z.enum(["active", "archived"]),
106
+ firstRegisteredAt: IsoTime,
107
+ lastVerifiedAt: IsoTime.nullable(),
108
+ })
109
+
110
+ export type RegistryRegisterResponse =
111
+ | {result: "accepted"; entry: RegistryEntryView; manifest: ServedMiniappManifest}
112
+ | {result: "accepted_unverified"; entry: RegistryEntryView}
113
+ | {result: "not_a_miniapp"; reason: MiniappRegistryNotAMiniappReason}
114
+ | {result: "archived"}
115
+
116
+ export const RegistryRegisterResponseSchema: z.ZodType<RegistryRegisterResponse> = z.discriminatedUnion("result", [
117
+ z.object({result: z.literal("accepted"), entry: RegistryEntryViewSchema, manifest: ServedMiniappManifestSchema}),
118
+ z.object({result: z.literal("accepted_unverified"), entry: RegistryEntryViewSchema}),
119
+ z.object({result: z.literal("not_a_miniapp"), reason: z.enum(MINIAPP_REGISTRY_NOT_A_MINIAPP_REASONS)}),
120
+ z.object({result: z.literal("archived")}),
121
+ ])
122
+
123
+ export const RegistryStatusRequestSchema = z.object({
124
+ entries: z
125
+ .array(
126
+ z.object({
127
+ url: z.string().min(1).max(MAX_URL_LENGTH),
128
+ /** The installed version. */
129
+ version: z.string().min(1).max(MAX_VERSION_LENGTH),
130
+ }),
131
+ )
132
+ .max(MINIAPP_REGISTRY_STATUS_MAX_ENTRIES),
133
+ })
134
+ export type RegistryStatusRequest = z.infer<typeof RegistryStatusRequestSchema>
135
+
136
+ /** `unknown`: the URL isn't in the registry, or isn't a miniapp URL. */
137
+ export type MiniappRegistryUrlStatus = "active" | "archived" | "unknown"
138
+
139
+ export interface RegistryStatusResponse {
140
+ /** One per requested entry, in order, with the URL as the phone sent it. */
141
+ statuses: Array<{url: string; status: MiniappRegistryUrlStatus}>
142
+ /** How long the answer stays valid, in seconds. */
143
+ validForSeconds: number
144
+ }
145
+
146
+ export const RegistryStatusResponseSchema: z.ZodType<RegistryStatusResponse> = z.object({
147
+ statuses: z.array(z.object({url: z.string(), status: z.enum(["active", "archived", "unknown"])})),
148
+ validForSeconds: z.number().int().positive(),
149
+ })
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @fileoverview Miniapp URLs, and the package names each may serve.
3
+ *
4
+ * A miniapp URL is a base URL that serves `miniapp.json` and the files it names. A miniapp's package name is its
5
+ * identity, so a name may only be served from a domain that owns it: the URL's reversed registrable domain, plus at
6
+ * least one more part (`notes.example.com` may serve `com.example.*`). Core checks this when it verifies a
7
+ * registration and the engine checks it on every install and update, both from this one implementation.
8
+ *
9
+ * Registrable domains come from the Public Suffix List (tldts), private section included, as browsers use it to scope
10
+ * cookies, so each GitHub Pages user has their own namespace. Nobody owns names under an IP address, a local-network
11
+ * name, a suffix the list doesn't know (`.internal`, `.test`) or `.arpa`, so those have no registrable domain.
12
+ *
13
+ * Every input is parsed the same way on Bun and on Hermes, whose URL implementation has no IDN support: anything
14
+ * outside printable ASCII is refused, and IDN hosts are written in their `xn--` form.
15
+ */
16
+ import {parse as parseDomain} from "tldts"
17
+ import {z} from "zod"
18
+
19
+ /** A public miniapp URL is HTTPS; a local-network one, on the developer's own network, may be HTTP. */
20
+ export type MiniappHostKind = "public" | "local"
21
+
22
+ export interface NormalizedMiniappUrl {
23
+ /** The canonical URL: lowercase scheme and host, no default port, a path that ends in `/`. */
24
+ url: string
25
+ /** The URL's hostname: lowercase, without a port or a trailing dot; an IPv6 address keeps its brackets. */
26
+ host: string
27
+ kind: MiniappHostKind
28
+ }
29
+
30
+ /** Package names: lowercase dot-separated parts that start with a letter, with no hyphens. */
31
+ export const MINIAPP_PACKAGE_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
32
+
33
+ /** Apple's age categories, which `ageRating` in `miniapp.json` takes. */
34
+ export const MINIAPP_AGE_RATINGS = ["4+", "9+", "13+", "16+", "18+"] as const
35
+ export type MiniappAgeRating = (typeof MINIAPP_AGE_RATINGS)[number]
36
+
37
+ /** `com.mentra.*` belongs to mentraglass.com, which Mentra owns; mentra.com is not Mentra's. */
38
+ const MENTRA_PACKAGE_PREFIX = "com.mentra."
39
+ const MENTRA_DOMAIN_PREFIX = "com.mentraglass"
40
+
41
+ const MAX_URL_LENGTH = 2048
42
+ const MAX_HOST_LENGTH = 253
43
+ const PRINTABLE_ASCII = /^[\x21-\x7e]+$/
44
+ const SCHEME_AND_AUTHORITY = /^https?:\/\/([^/?#]*)/i
45
+ const HOST_CHARACTERS = /^[a-z0-9._:[\]-]+$/i
46
+ const HOST_LABEL = /^[a-z0-9_-]{1,63}$/
47
+ const PACKAGE_PART = /^[a-z][a-z0-9_]*$/
48
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
49
+ const IPV6_GROUP = /^[0-9a-f]{1,4}$/
50
+
51
+ /**
52
+ * Normalize a miniapp URL, or answer null when it isn't one: lowercase the scheme and host, drop the default port and
53
+ * a trailing dot, and end the path in `/`. Credentials, a query and a fragment are refused. A public URL must be
54
+ * HTTPS; a local-network URL ({@link isLocalNetworkHost}) may be HTTP or HTTPS.
55
+ */
56
+ export function normalizeMiniappUrl(input: string): NormalizedMiniappUrl | null {
57
+ const parsed = parseHttpUrl(input)
58
+ if (!parsed) return null
59
+ // Any `?` or `#` starts a query or a fragment, even an empty one.
60
+ if (/[?#]/.test(input)) return null
61
+ const kind: MiniappHostKind = isLocalCanonicalHost(parsed.host) ? "local" : "public"
62
+ if (kind === "public" && parsed.url.protocol !== "https:") return null
63
+ const port = parsed.url.port ? `:${parsed.url.port}` : ""
64
+ const path = parsed.url.pathname.endsWith("/") ? parsed.url.pathname : `${parsed.url.pathname}/`
65
+ return {url: `${parsed.url.protocol}//${parsed.host}${port}${path}`, host: parsed.host, kind}
66
+ }
67
+
68
+ /**
69
+ * The registrable domain of a host, from the Public Suffix List: `notes.example.com` is `example.com`, and
70
+ * `alice.github.io` is itself. Null for a public suffix, an IP address, a local-network name, a suffix the list
71
+ * doesn't know, `.arpa`, or anything that isn't a hostname.
72
+ */
73
+ export function registrableDomain(host: string): string | null {
74
+ const canonical = canonicalHost(host)
75
+ return canonical ? registrableDomainOf(canonical) : null
76
+ }
77
+
78
+ /**
79
+ * The package-name prefix a host's domain owns: its reversed registrable domain, with each hyphen as an underscore
80
+ * (`notes.my-site.com` is `com.my_site`). Null when the host has no registrable domain, or when the domain can't map
81
+ * to package-name parts: a label that starts with a digit, or one with an underscore, which could collide with a
82
+ * mapped hyphen.
83
+ */
84
+ export function packagePrefixForHost(host: string): string | null {
85
+ const canonical = canonicalHost(host)
86
+ return canonical ? packagePrefixOf(canonical) : null
87
+ }
88
+
89
+ /**
90
+ * The domain rule: whether a miniapp URL may serve this package name. The name must start with the prefix its host's
91
+ * domain owns ({@link packagePrefixForHost}) plus at least one more part, and `com.mentra.*` is served only from
92
+ * mentraglass.com. Only the host decides, so this also checks where a redirect ended, query and all. A URL that isn't
93
+ * public HTTPS serves no name: a local-network address has no domain to own one.
94
+ */
95
+ export function isPackageNameAllowedForUrl(packageName: string, url: string): boolean {
96
+ if (typeof packageName !== "string" || !MINIAPP_PACKAGE_NAME_PATTERN.test(packageName)) return false
97
+ const parsed = parseHttpUrl(url)
98
+ if (!parsed || parsed.url.protocol !== "https:" || isLocalCanonicalHost(parsed.host)) return false
99
+ const prefix = packagePrefixOf(parsed.host)
100
+ if (!prefix) return false
101
+ if (packageName.startsWith(MENTRA_PACKAGE_PREFIX)) return prefix === MENTRA_DOMAIN_PREFIX
102
+ return packageName.startsWith(`${prefix}.`)
103
+ }
104
+
105
+ /**
106
+ * Whether a host is on the local network: `localhost` and `*.localhost`, `*.local` names, and loopback, private,
107
+ * link-local and shared (100.64.0.0/10) IPv4 addresses, and loopback, unique local and link-local IPv6 addresses,
108
+ * including IPv4 addresses mapped into IPv6. Takes a hostname, with or without IPv6 brackets.
109
+ */
110
+ export function isLocalNetworkHost(host: string): boolean {
111
+ const canonical = canonicalHost(host)
112
+ return canonical !== null && isLocalCanonicalHost(canonical)
113
+ }
114
+
115
+ /** Parse an absolute HTTP or HTTPS URL without credentials, the same way on every runtime. */
116
+ function parseHttpUrl(input: string): {url: URL; host: string} | null {
117
+ if (typeof input !== "string") return null
118
+ const value = input.trim()
119
+ if (value.length === 0 || value.length > MAX_URL_LENGTH) return null
120
+ // URL parsers differ on non-ASCII and on backslashes, so neither is accepted.
121
+ if (!PRINTABLE_ASCII.test(value) || value.includes("\\")) return null
122
+ const authority = SCHEME_AND_AUTHORITY.exec(value)
123
+ if (!authority || authority[1]!.includes("@")) return null
124
+ let url: URL
125
+ try {
126
+ url = new URL(value)
127
+ } catch {
128
+ return null
129
+ }
130
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null
131
+ if (url.username || url.password) return null
132
+ const host = canonicalHostname(url.hostname)
133
+ return host ? {url, host} : null
134
+ }
135
+
136
+ /** A hostname given on its own, canonicalized as a URL's would be: `127.1` is `127.0.0.1`. */
137
+ function canonicalHost(host: string): string | null {
138
+ if (typeof host !== "string" || !HOST_CHARACTERS.test(host)) return null
139
+ const candidate = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
140
+ let url: URL
141
+ try {
142
+ url = new URL(`http://${candidate}/`)
143
+ } catch {
144
+ return null
145
+ }
146
+ // A port, or anything else that isn't part of the hostname.
147
+ if (url.host !== url.hostname) return null
148
+ return canonicalHostname(url.hostname)
149
+ }
150
+
151
+ /** A URL's hostname, lowercase, without a trailing dot, and with each label checked. */
152
+ function canonicalHostname(hostname: string): string | null {
153
+ let host = hostname.toLowerCase()
154
+ if (host.startsWith("[")) return host.endsWith("]") && parseIpv6(host.slice(1, -1)) ? host : null
155
+ if (host.endsWith(".")) host = host.slice(0, -1)
156
+ if (host.length === 0 || host.length > MAX_HOST_LENGTH) return null
157
+ if (IPV4.test(host)) return parseIpv4(host) ? host : null
158
+ const valid = host
159
+ .split(".")
160
+ .every((label) => HOST_LABEL.test(label) && !label.startsWith("-") && !label.endsWith("-"))
161
+ return valid ? host : null
162
+ }
163
+
164
+ function isLocalCanonicalHost(host: string): boolean {
165
+ if (host.startsWith("[")) {
166
+ const words = parseIpv6(host.slice(1, -1))
167
+ return words !== null && isLocalIpv6(words)
168
+ }
169
+ const ipv4 = parseIpv4(host)
170
+ if (ipv4) return isLocalIpv4(ipv4)
171
+ return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")
172
+ }
173
+
174
+ function registrableDomainOf(host: string): string | null {
175
+ if (host.startsWith("[") || parseIpv4(host) || isLocalCanonicalHost(host)) return null
176
+ const parsed = parseDomain(host, {allowPrivateDomains: true})
177
+ if (parsed.hostname !== host || !parsed.domain || !parsed.publicSuffix) return null
178
+ // A suffix the list doesn't know matches only its implicit `*` rule; nobody owns names under it.
179
+ if (!parsed.isIcann && !parsed.isPrivate) return null
180
+ if (parsed.publicSuffix === "arpa" || parsed.publicSuffix.endsWith(".arpa")) return null
181
+ return parsed.domain
182
+ }
183
+
184
+ function packagePrefixOf(host: string): string | null {
185
+ const domain = registrableDomainOf(host)
186
+ if (!domain) return null
187
+ const parts: string[] = []
188
+ for (const label of domain.split(".").reverse()) {
189
+ if (label.includes("_")) return null
190
+ const part = label.replace(/-/g, "_")
191
+ if (!PACKAGE_PART.test(part)) return null
192
+ parts.push(part)
193
+ }
194
+ return parts.join(".")
195
+ }
196
+
197
+ function parseIpv4(text: string): number[] | null {
198
+ const match = IPV4.exec(text)
199
+ if (!match) return null
200
+ const octets = match.slice(1).map((octet) => (/^(0|[1-9]\d*)$/.test(octet!) ? Number(octet) : 256))
201
+ return octets.every((octet) => octet <= 255) ? octets : null
202
+ }
203
+
204
+ function isLocalIpv4([a, b]: number[]): boolean {
205
+ return (
206
+ a === 10 ||
207
+ a === 127 ||
208
+ (a === 172 && b! >= 16 && b! <= 31) ||
209
+ (a === 192 && b === 168) ||
210
+ (a === 169 && b === 254) ||
211
+ (a === 100 && b! >= 64 && b! <= 127)
212
+ )
213
+ }
214
+
215
+ /** Eight 16-bit words, from an IPv6 address without brackets. */
216
+ function parseIpv6(text: string): number[] | null {
217
+ const halves = text.toLowerCase().split("::")
218
+ if (halves.length > 2) return null
219
+ const compressed = halves.length === 2
220
+ const head = parseIpv6Groups(halves[0]!, !compressed)
221
+ const tail = compressed ? parseIpv6Groups(halves[1]!, true) : []
222
+ if (!head || !tail) return null
223
+ if (!compressed) return head.length === 8 ? head : null
224
+ const missing = 8 - head.length - tail.length
225
+ return missing >= 1 ? [...head, ...new Array<number>(missing).fill(0), ...tail] : null
226
+ }
227
+
228
+ /** IPv6 groups; the last group of the address may be a dotted IPv4 address. */
229
+ function parseIpv6Groups(part: string, endsAddress: boolean): number[] | null {
230
+ if (part === "") return []
231
+ const groups = part.split(":")
232
+ const words: number[] = []
233
+ for (let index = 0; index < groups.length; index += 1) {
234
+ const group = groups[index]!
235
+ if (endsAddress && index === groups.length - 1 && group.includes(".")) {
236
+ const ipv4 = parseIpv4(group)
237
+ if (!ipv4) return null
238
+ words.push((ipv4[0]! << 8) | ipv4[1]!, (ipv4[2]! << 8) | ipv4[3]!)
239
+ } else if (IPV6_GROUP.test(group)) {
240
+ words.push(parseInt(group, 16))
241
+ } else {
242
+ return null
243
+ }
244
+ }
245
+ return words
246
+ }
247
+
248
+ function isLocalIpv6(words: number[]): boolean {
249
+ const first = words[0]!
250
+ const isLoopback = words.slice(0, 7).every((word) => word === 0) && words[7] === 1
251
+ const isUniqueLocal = (first & 0xfe00) === 0xfc00
252
+ const isLinkLocal = (first & 0xffc0) === 0xfe80
253
+ const isMappedIpv4 = words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff
254
+ if (isMappedIpv4) return isLocalIpv4([words[6]! >> 8, words[6]! & 0xff, words[7]! >> 8, words[7]! & 0xff])
255
+ return isLoopback || isUniqueLocal || isLinkLocal
256
+ }
257
+
258
+ /** SemVer 2.0.0 (semver.org): `1.4.2`, `1.4.2-beta.1`; not `v1.4.2` or `1.4`. The `version` of a served `miniapp.json`. */
259
+ export const MINIAPP_VERSION_PATTERN =
260
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
261
+
262
+ /** What a miniapp URL serves at `<url>/miniapp.json`: the manifest inside its current ZIP, plus `bundle`. */
263
+ export interface ServedMiniappManifest {
264
+ packageName: string
265
+ version: string
266
+ name: string
267
+ description?: string
268
+ /** Relative to the miniapp URL, or an absolute URL. */
269
+ icon?: string
270
+ /** Absent: the Mentra App's own rating, so the miniapp is never age-gated. */
271
+ ageRating?: MiniappAgeRating
272
+ sdkVersion?: string
273
+ minHostVersion?: string
274
+ hardwareRequirements?: Array<{type: string; level: string; description?: string}>
275
+ /** The directory's listing; the phone ignores it, so it isn't checked here. */
276
+ listing?: unknown
277
+ /** The ZIP: its URL, relative to the miniapp URL or absolute, and its SHA-256, in lowercase hex. */
278
+ bundle: {url: string; sha256: string}
279
+ }
280
+
281
+ const NonBlankString = z.string().refine((value) => value.trim().length > 0, "must not be blank")
282
+
283
+ /**
284
+ * The served `miniapp.json`. Fields this schema doesn't name, such as `entry` and `permissions`, are kept as served;
285
+ * the engine checks them in the ZIP's own copy when it installs.
286
+ */
287
+ export const ServedMiniappManifestSchema: z.ZodType<ServedMiniappManifest> = z
288
+ .object({
289
+ packageName: z.string().regex(MINIAPP_PACKAGE_NAME_PATTERN),
290
+ version: z.string().regex(MINIAPP_VERSION_PATTERN),
291
+ name: NonBlankString,
292
+ description: z.string().optional(),
293
+ icon: NonBlankString.optional(),
294
+ ageRating: z.enum(MINIAPP_AGE_RATINGS).optional(),
295
+ sdkVersion: NonBlankString.optional(),
296
+ minHostVersion: NonBlankString.optional(),
297
+ hardwareRequirements: z
298
+ .array(z.object({type: z.string(), level: z.string(), description: z.string().optional()}).passthrough())
299
+ .optional(),
300
+ listing: z.unknown(),
301
+ bundle: z
302
+ .object({
303
+ url: NonBlankString,
304
+ sha256: z
305
+ .string()
306
+ .regex(/^[0-9a-fA-F]{64}$/)
307
+ .transform((sha256) => sha256.toLowerCase()),
308
+ })
309
+ .passthrough(),
310
+ })
311
+ .passthrough()