@mentra/cloud-protocol 3.4.0-dev.1083 → 3.4.0-dev.1087

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.1083",
3
+ "version": "3.4.0-dev.1087",
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
+ }
package/src/index.ts CHANGED
@@ -18,3 +18,4 @@ export * from "./camera";
18
18
  export * from "./maps";
19
19
  export * from "./report-automation";
20
20
  export * from "./fleet";
21
+ export * from "./miniapp-url";
@@ -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`. */
259
+ const SEMVER =
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(SEMVER),
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()