@mentra/cloud-protocol 3.4.0-dev.1085 → 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 +6 -4
- package/package.json +2 -1
- package/src/__fixtures__/miniapp-url-vectors.json +194 -0
- package/src/index.ts +1 -0
- package/src/miniapp-url.ts +311 -0
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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.
|
|
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
|
@@ -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()
|