@mentra/cloud-protocol 3.4.0-dev.999 → 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 +6 -4
- package/package.json +2 -1
- package/src/__fixtures__/miniapp-url-vectors.json +194 -0
- package/src/declared-age.ts +55 -0
- package/src/fleet.ts +341 -4
- package/src/index.ts +3 -0
- package/src/miniapp-registry.ts +149 -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.
|
|
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
|
@@ -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()
|