@napplet/conformance-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/cli.js +526 -0
- package/dist/cli.js.map +1 -0
- package/dist/host-bundle.global.js +775 -0
- package/dist/ui/assets/index-D4WUxbBl.css +1 -0
- package/dist/ui/assets/index-iVwcmekY.js +34 -0
- package/dist/ui/index.html +14 -0
- package/package.json +45 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var NappletConformanceHostBundle = (() => {
|
|
3
|
+
// ../core/dist/index.js
|
|
4
|
+
var NAP_DOMAINS = ["relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "connect", "class", "cvm", "outbox", "upload", "intent"];
|
|
5
|
+
function createDispatch() {
|
|
6
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
7
|
+
function registerNap2(domain, handler) {
|
|
8
|
+
if (handlers.has(domain)) {
|
|
9
|
+
throw new Error(`NAP domain "${domain}" is already registered`);
|
|
10
|
+
}
|
|
11
|
+
handlers.set(domain, handler);
|
|
12
|
+
}
|
|
13
|
+
function dispatch2(message) {
|
|
14
|
+
const dotIndex = message.type.indexOf(".");
|
|
15
|
+
if (dotIndex <= 0) return false;
|
|
16
|
+
const domain = message.type.slice(0, dotIndex);
|
|
17
|
+
const handler = handlers.get(domain);
|
|
18
|
+
if (!handler) return false;
|
|
19
|
+
handler(message);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function getRegisteredDomains2() {
|
|
23
|
+
return Array.from(handlers.keys());
|
|
24
|
+
}
|
|
25
|
+
return { registerNap: registerNap2, dispatch: dispatch2, getRegisteredDomains: getRegisteredDomains2 };
|
|
26
|
+
}
|
|
27
|
+
var _default = createDispatch();
|
|
28
|
+
var registerNap = _default.registerNap;
|
|
29
|
+
var dispatch = _default.dispatch;
|
|
30
|
+
var getRegisteredDomains = _default.getRegisteredDomains;
|
|
31
|
+
|
|
32
|
+
// ../nap/dist/chunk-ESA5HG5Q.js
|
|
33
|
+
var ERR_PREFIX = "[@napplet/nap/connect]";
|
|
34
|
+
var ACCEPTED_SCHEMES = /* @__PURE__ */ new Set(["https", "wss", "http", "ws"]);
|
|
35
|
+
var DEFAULT_PORTS = {
|
|
36
|
+
https: "443",
|
|
37
|
+
wss: "443",
|
|
38
|
+
http: "80",
|
|
39
|
+
ws: "80"
|
|
40
|
+
};
|
|
41
|
+
function splitOriginScheme(origin) {
|
|
42
|
+
const schemeIdx = origin.indexOf("://");
|
|
43
|
+
if (schemeIdx < 0) {
|
|
44
|
+
throw new Error(`${ERR_PREFIX} origin missing scheme separator '://': ${origin}`);
|
|
45
|
+
}
|
|
46
|
+
const scheme = origin.slice(0, schemeIdx);
|
|
47
|
+
if (scheme !== scheme.toLowerCase()) {
|
|
48
|
+
throw new Error(`${ERR_PREFIX} scheme must be lowercase: ${origin}`);
|
|
49
|
+
}
|
|
50
|
+
if (!ACCEPTED_SCHEMES.has(scheme)) {
|
|
51
|
+
throw new Error(`${ERR_PREFIX} scheme must be one of https|wss|http|ws: ${origin}`);
|
|
52
|
+
}
|
|
53
|
+
return { scheme, rest: origin.slice(schemeIdx + 3) };
|
|
54
|
+
}
|
|
55
|
+
function assertOriginRest(rest, origin) {
|
|
56
|
+
if (rest.includes("/")) {
|
|
57
|
+
throw new Error(`${ERR_PREFIX} origin MUST NOT contain a path: ${origin}`);
|
|
58
|
+
}
|
|
59
|
+
if (rest.includes("?")) {
|
|
60
|
+
throw new Error(`${ERR_PREFIX} origin MUST NOT contain a query: ${origin}`);
|
|
61
|
+
}
|
|
62
|
+
if (rest.includes("#")) {
|
|
63
|
+
throw new Error(`${ERR_PREFIX} origin MUST NOT contain a fragment: ${origin}`);
|
|
64
|
+
}
|
|
65
|
+
if (rest.includes("*")) {
|
|
66
|
+
throw new Error(`${ERR_PREFIX} wildcard origins are not permitted: ${origin}`);
|
|
67
|
+
}
|
|
68
|
+
if (rest.startsWith("[")) {
|
|
69
|
+
throw new Error(`${ERR_PREFIX} IPv6 literal origins are out of v1 scope: ${origin}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function parseHost(scheme, rest, origin) {
|
|
73
|
+
let host = rest;
|
|
74
|
+
const portColonIdx = rest.lastIndexOf(":");
|
|
75
|
+
if (portColonIdx >= 0) {
|
|
76
|
+
host = rest.slice(0, portColonIdx);
|
|
77
|
+
const port = rest.slice(portColonIdx + 1);
|
|
78
|
+
if (!/^[0-9]+$/.test(port)) {
|
|
79
|
+
throw new Error(`${ERR_PREFIX} port must be decimal digits: ${origin}`);
|
|
80
|
+
}
|
|
81
|
+
const portNum = Number(port);
|
|
82
|
+
if (portNum < 1 || portNum > 65535) {
|
|
83
|
+
throw new Error(`${ERR_PREFIX} port out of range 1-65535: ${origin}`);
|
|
84
|
+
}
|
|
85
|
+
if (DEFAULT_PORTS[scheme] === port) {
|
|
86
|
+
throw new Error(`${ERR_PREFIX} default port ${port} MUST be omitted for scheme ${scheme}: ${origin}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
validateHostBase(host, origin);
|
|
90
|
+
return host;
|
|
91
|
+
}
|
|
92
|
+
function validateHostBase(host, origin) {
|
|
93
|
+
if (host.includes(":")) {
|
|
94
|
+
throw new Error(`${ERR_PREFIX} IPv6 literal origins are out of v1 scope: ${origin}`);
|
|
95
|
+
}
|
|
96
|
+
if (host.length === 0) {
|
|
97
|
+
throw new Error(`${ERR_PREFIX} host is empty: ${origin}`);
|
|
98
|
+
}
|
|
99
|
+
if (host !== host.toLowerCase()) {
|
|
100
|
+
throw new Error(`${ERR_PREFIX} host MUST be lowercase: ${origin}`);
|
|
101
|
+
}
|
|
102
|
+
if (!/^[\x00-\x7F]+$/.test(host)) {
|
|
103
|
+
throw new Error(`${ERR_PREFIX} non-ASCII host MUST be Punycode-encoded (xn--...) before calling: ${origin}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validateIpv4Host(host, origin) {
|
|
107
|
+
if (!/^[0-9]+(\.[0-9]+){3}$/.test(host)) return false;
|
|
108
|
+
for (const oct of host.split(".")) {
|
|
109
|
+
if (oct.length > 1 && oct.startsWith("0")) {
|
|
110
|
+
throw new Error(`${ERR_PREFIX} IPv4 octet MUST NOT have leading zeros: ${origin}`);
|
|
111
|
+
}
|
|
112
|
+
const n = Number(oct);
|
|
113
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) {
|
|
114
|
+
throw new Error(`${ERR_PREFIX} IPv4 octet out of range 0-255: ${origin}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function validateDnsHost(host, origin) {
|
|
120
|
+
for (const label of host.split(".")) {
|
|
121
|
+
if (label.length === 0 || label.length > 63) {
|
|
122
|
+
throw new Error(`${ERR_PREFIX} invalid host label length: ${origin}`);
|
|
123
|
+
}
|
|
124
|
+
if (!/^[a-z0-9-]+$/.test(label)) {
|
|
125
|
+
throw new Error(`${ERR_PREFIX} host label contains invalid characters: ${origin}`);
|
|
126
|
+
}
|
|
127
|
+
if (label.startsWith("-") || label.endsWith("-")) {
|
|
128
|
+
throw new Error(`${ERR_PREFIX} host label MUST NOT start or end with hyphen: ${origin}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function normalizeConnectOrigin(origin) {
|
|
133
|
+
if (typeof origin !== "string" || origin.length === 0) {
|
|
134
|
+
throw new Error(`${ERR_PREFIX} origin must be a non-empty string`);
|
|
135
|
+
}
|
|
136
|
+
const { scheme, rest } = splitOriginScheme(origin);
|
|
137
|
+
assertOriginRest(rest, origin);
|
|
138
|
+
const host = parseHost(scheme, rest, origin);
|
|
139
|
+
if (!validateIpv4Host(host, origin)) {
|
|
140
|
+
validateDnsHost(host, origin);
|
|
141
|
+
}
|
|
142
|
+
return origin;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ../conformance/dist/index.js
|
|
146
|
+
var ID = { id: "string" };
|
|
147
|
+
var ENVELOPE_SPECS = {
|
|
148
|
+
// ── relay ────────────────────────────────────────────────────────────────
|
|
149
|
+
"relay.subscribe": { dir: "out", fields: { ...ID, subId: "string", filters: "array" } },
|
|
150
|
+
"relay.close": { dir: "out", fields: { ...ID, subId: "string" } },
|
|
151
|
+
"relay.publish": { dir: "out", fields: { ...ID, event: "object" } },
|
|
152
|
+
"relay.publishEncrypted": { dir: "out", fields: { ...ID, event: "object", recipient: "string" } },
|
|
153
|
+
"relay.query": { dir: "out", fields: { ...ID, filters: "array" } },
|
|
154
|
+
"relay.event": { dir: "in" },
|
|
155
|
+
"relay.eose": { dir: "in" },
|
|
156
|
+
"relay.closed": { dir: "in" },
|
|
157
|
+
"relay.publish.result": { dir: "in" },
|
|
158
|
+
"relay.publishEncrypted.result": { dir: "in" },
|
|
159
|
+
"relay.query.result": { dir: "in" },
|
|
160
|
+
// ── identity ─────────────────────────────────────────────────────────────
|
|
161
|
+
"identity.getPublicKey": { dir: "out", fields: { ...ID } },
|
|
162
|
+
"identity.getRelays": { dir: "out", fields: { ...ID } },
|
|
163
|
+
"identity.getProfile": { dir: "out", fields: { ...ID } },
|
|
164
|
+
"identity.getFollows": { dir: "out", fields: { ...ID } },
|
|
165
|
+
"identity.getList": { dir: "out", fields: { ...ID, listType: "string" } },
|
|
166
|
+
"identity.getZaps": { dir: "out", fields: { ...ID } },
|
|
167
|
+
"identity.getMutes": { dir: "out", fields: { ...ID } },
|
|
168
|
+
"identity.getBlocked": { dir: "out", fields: { ...ID } },
|
|
169
|
+
"identity.getBadges": { dir: "out", fields: { ...ID } },
|
|
170
|
+
"identity.changed": { dir: "in" },
|
|
171
|
+
"identity.getPublicKey.result": { dir: "in" },
|
|
172
|
+
"identity.getRelays.result": { dir: "in" },
|
|
173
|
+
"identity.getProfile.result": { dir: "in" },
|
|
174
|
+
"identity.getFollows.result": { dir: "in" },
|
|
175
|
+
"identity.getList.result": { dir: "in" },
|
|
176
|
+
"identity.getZaps.result": { dir: "in" },
|
|
177
|
+
"identity.getMutes.result": { dir: "in" },
|
|
178
|
+
"identity.getBlocked.result": { dir: "in" },
|
|
179
|
+
"identity.getBadges.result": { dir: "in" },
|
|
180
|
+
// ── storage ──────────────────────────────────────────────────────────────
|
|
181
|
+
"storage.get": { dir: "out", fields: { ...ID, key: "string" } },
|
|
182
|
+
"storage.set": { dir: "out", fields: { ...ID, key: "string", value: "string" } },
|
|
183
|
+
"storage.remove": { dir: "out", fields: { ...ID, key: "string" } },
|
|
184
|
+
"storage.keys": { dir: "out", fields: { ...ID } },
|
|
185
|
+
"storage.get.result": { dir: "in" },
|
|
186
|
+
"storage.set.result": { dir: "in" },
|
|
187
|
+
"storage.remove.result": { dir: "in" },
|
|
188
|
+
"storage.keys.result": { dir: "in" },
|
|
189
|
+
// ── inc (inter-napplet communication) ─────────────────────────────────────
|
|
190
|
+
"inc.emit": { dir: "out", fields: { topic: "string" } },
|
|
191
|
+
"inc.subscribe": { dir: "out", fields: { ...ID, topic: "string" } },
|
|
192
|
+
"inc.unsubscribe": { dir: "out", fields: { topic: "string" } },
|
|
193
|
+
"inc.channel.open": { dir: "out", fields: { ...ID, target: "string" } },
|
|
194
|
+
"inc.channel.emit": { dir: "out", fields: { channelId: "string" } },
|
|
195
|
+
"inc.channel.broadcast": { dir: "out" },
|
|
196
|
+
"inc.channel.list": { dir: "out", fields: { ...ID } },
|
|
197
|
+
"inc.channel.close": { dir: "out", fields: { channelId: "string" } },
|
|
198
|
+
"inc.subscribe.result": { dir: "in" },
|
|
199
|
+
"inc.event": { dir: "in" },
|
|
200
|
+
"inc.channel.open.result": { dir: "in" },
|
|
201
|
+
"inc.channel.event": { dir: "in" },
|
|
202
|
+
"inc.channel.list.result": { dir: "in" },
|
|
203
|
+
"inc.channel.closed": { dir: "in" },
|
|
204
|
+
// ── theme ────────────────────────────────────────────────────────────────
|
|
205
|
+
"theme.get": { dir: "out", fields: { ...ID } },
|
|
206
|
+
"theme.get.result": { dir: "in" },
|
|
207
|
+
"theme.changed": { dir: "in" },
|
|
208
|
+
// ── keys ─────────────────────────────────────────────────────────────────
|
|
209
|
+
"keys.forward": {
|
|
210
|
+
dir: "out",
|
|
211
|
+
fields: { key: "string", code: "string", ctrl: "boolean", alt: "boolean", shift: "boolean", meta: "boolean" }
|
|
212
|
+
},
|
|
213
|
+
"keys.registerAction": { dir: "out", fields: { ...ID, action: "object" } },
|
|
214
|
+
"keys.unregisterAction": { dir: "out", fields: { actionId: "string" } },
|
|
215
|
+
"keys.registerAction.result": { dir: "in" },
|
|
216
|
+
"keys.bindings": { dir: "in" },
|
|
217
|
+
"keys.action": { dir: "in" },
|
|
218
|
+
// ── media ────────────────────────────────────────────────────────────────
|
|
219
|
+
"media.session.create": { dir: "out", fields: { ...ID, owner: "object" } },
|
|
220
|
+
"media.session.update": { dir: "out", fields: { sessionId: "string", metadata: "object" } },
|
|
221
|
+
"media.session.destroy": { dir: "out", fields: { sessionId: "string" } },
|
|
222
|
+
"media.state": { dir: "out", fields: { sessionId: "string", status: "string" } },
|
|
223
|
+
"media.capabilities": { dir: "out", fields: { sessionId: "string", actions: "array" } },
|
|
224
|
+
"media.session.create.result": { dir: "in" },
|
|
225
|
+
"media.command": { dir: "in" },
|
|
226
|
+
"media.controls": { dir: "in" },
|
|
227
|
+
// ── notify ───────────────────────────────────────────────────────────────
|
|
228
|
+
"notify.send": { dir: "out", fields: { ...ID, title: "string" } },
|
|
229
|
+
"notify.dismiss": { dir: "out", fields: { notificationId: "string" } },
|
|
230
|
+
"notify.badge": { dir: "out", fields: { count: "number" } },
|
|
231
|
+
"notify.channel.register": { dir: "out", fields: { channelId: "string", label: "string" } },
|
|
232
|
+
"notify.permission.request": { dir: "out", fields: { ...ID } },
|
|
233
|
+
"notify.send.result": { dir: "in" },
|
|
234
|
+
"notify.permission.result": { dir: "in" },
|
|
235
|
+
"notify.action": { dir: "in" },
|
|
236
|
+
"notify.clicked": { dir: "in" },
|
|
237
|
+
"notify.dismissed": { dir: "in" },
|
|
238
|
+
"notify.controls": { dir: "in" },
|
|
239
|
+
// ── config ───────────────────────────────────────────────────────────────
|
|
240
|
+
"config.registerSchema": { dir: "out", fields: { ...ID, schema: "object" } },
|
|
241
|
+
"config.get": { dir: "out", fields: { ...ID } },
|
|
242
|
+
"config.subscribe": { dir: "out" },
|
|
243
|
+
"config.unsubscribe": { dir: "out" },
|
|
244
|
+
"config.openSettings": { dir: "out" },
|
|
245
|
+
"config.registerSchema.result": { dir: "in" },
|
|
246
|
+
"config.values": { dir: "in" },
|
|
247
|
+
"config.schemaError": { dir: "in" },
|
|
248
|
+
// ── resource ─────────────────────────────────────────────────────────────
|
|
249
|
+
"resource.bytes": { dir: "out", fields: { ...ID, url: "string" } },
|
|
250
|
+
"resource.cancel": { dir: "out", fields: { ...ID } },
|
|
251
|
+
"resource.bytes.result": { dir: "in" },
|
|
252
|
+
"resource.bytes.error": { dir: "in" },
|
|
253
|
+
// ── class (shell-initiated only) ─────────────────────────────────────────
|
|
254
|
+
"class.assigned": { dir: "in" },
|
|
255
|
+
// ── cvm ──────────────────────────────────────────────────────────────────
|
|
256
|
+
"cvm.discover": { dir: "out", fields: { ...ID } },
|
|
257
|
+
"cvm.request": { dir: "out", fields: { ...ID, server: "object", message: "object" } },
|
|
258
|
+
"cvm.close": { dir: "out", fields: { ...ID, server: "object" } },
|
|
259
|
+
"cvm.discover.result": { dir: "in" },
|
|
260
|
+
"cvm.request.result": { dir: "in" },
|
|
261
|
+
"cvm.close.result": { dir: "in" },
|
|
262
|
+
"cvm.event": { dir: "in" },
|
|
263
|
+
// ── outbox ───────────────────────────────────────────────────────────────
|
|
264
|
+
"outbox.query": { dir: "out", fields: { ...ID, filters: "present" } },
|
|
265
|
+
"outbox.subscribe": { dir: "out", fields: { ...ID, subId: "string", filters: "present" } },
|
|
266
|
+
"outbox.close": { dir: "out", fields: { ...ID, subId: "string" } },
|
|
267
|
+
"outbox.publish": { dir: "out", fields: { ...ID, event: "object" } },
|
|
268
|
+
"outbox.resolveRelays": { dir: "out", fields: { ...ID, target: "present" } },
|
|
269
|
+
"outbox.query.result": { dir: "in" },
|
|
270
|
+
"outbox.event": { dir: "in" },
|
|
271
|
+
"outbox.eose": { dir: "in" },
|
|
272
|
+
"outbox.closed": { dir: "in" },
|
|
273
|
+
"outbox.publish.result": { dir: "in" },
|
|
274
|
+
"outbox.resolveRelays.result": { dir: "in" },
|
|
275
|
+
// ── upload ───────────────────────────────────────────────────────────────
|
|
276
|
+
"upload.upload": { dir: "out", fields: { ...ID, request: "object" } },
|
|
277
|
+
"upload.status": { dir: "out", fields: { ...ID, uploadId: "string" } },
|
|
278
|
+
"upload.upload.result": { dir: "in" },
|
|
279
|
+
"upload.status.result": { dir: "in" },
|
|
280
|
+
"upload.status.changed": { dir: "in" },
|
|
281
|
+
// ── intent ───────────────────────────────────────────────────────────────
|
|
282
|
+
"intent.invoke": { dir: "out", fields: { ...ID, request: "object" } },
|
|
283
|
+
"intent.available": { dir: "out", fields: { ...ID, archetype: "string" } },
|
|
284
|
+
"intent.handlers": { dir: "out", fields: { ...ID } },
|
|
285
|
+
"intent.invoke.result": { dir: "in" },
|
|
286
|
+
"intent.available.result": { dir: "in" },
|
|
287
|
+
"intent.handlers.result": { dir: "in" },
|
|
288
|
+
"intent.changed": { dir: "in" }
|
|
289
|
+
};
|
|
290
|
+
function kindOf(value) {
|
|
291
|
+
if (value === void 0) return "undefined";
|
|
292
|
+
if (value === null) return "null";
|
|
293
|
+
if (Array.isArray(value)) return "array";
|
|
294
|
+
const t = typeof value;
|
|
295
|
+
if (t === "string" || t === "number" || t === "boolean") return t;
|
|
296
|
+
if (t === "object") return "object";
|
|
297
|
+
return "present";
|
|
298
|
+
}
|
|
299
|
+
function matchesKind(value, kind) {
|
|
300
|
+
if (kind === "present") return value !== void 0 && value !== null;
|
|
301
|
+
return kindOf(value) === kind;
|
|
302
|
+
}
|
|
303
|
+
function validateEnvelope(message) {
|
|
304
|
+
const errors = [];
|
|
305
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) {
|
|
306
|
+
return { ok: false, errors: [{ code: "not-an-object", message: "Envelope must be a non-null object" }] };
|
|
307
|
+
}
|
|
308
|
+
const record = message;
|
|
309
|
+
const type = record["type"];
|
|
310
|
+
if (typeof type !== "string") {
|
|
311
|
+
return { ok: false, errors: [{ code: "missing-type", message: "Envelope is missing a string `type` field" }] };
|
|
312
|
+
}
|
|
313
|
+
const dotIndex = type.indexOf(".");
|
|
314
|
+
if (dotIndex <= 0) {
|
|
315
|
+
return { ok: false, type, errors: [{ code: "malformed-type", message: `Envelope type "${type}" is not in domain.action form` }] };
|
|
316
|
+
}
|
|
317
|
+
const domain = type.slice(0, dotIndex);
|
|
318
|
+
if (!NAP_DOMAINS.includes(domain)) {
|
|
319
|
+
return { ok: false, type, domain, errors: [{ code: "unknown-domain", message: `"${domain}" is not a known NAP domain` }] };
|
|
320
|
+
}
|
|
321
|
+
const spec = ENVELOPE_SPECS[type];
|
|
322
|
+
if (!spec) {
|
|
323
|
+
return { ok: false, type, domain, errors: [{ code: "unknown-type", message: `"${type}" is not a known ${domain} message type` }] };
|
|
324
|
+
}
|
|
325
|
+
if (spec.dir === "in") {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
type,
|
|
329
|
+
domain,
|
|
330
|
+
direction: "in",
|
|
331
|
+
errors: [{ code: "inbound-type-emitted", message: `"${type}" is a shell\u2192napplet message; a napplet must not emit it` }]
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
for (const [field, kind] of Object.entries(spec.fields ?? {})) {
|
|
335
|
+
if (!(field in record) || record[field] === void 0) {
|
|
336
|
+
errors.push({ code: "missing-field", message: `Required field "${field}" is missing`, field });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!matchesKind(record[field], kind)) {
|
|
340
|
+
errors.push({
|
|
341
|
+
code: "wrong-type",
|
|
342
|
+
message: `Field "${field}" should be ${kind} but is ${kindOf(record[field])}`,
|
|
343
|
+
field
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { ok: errors.length === 0, type, domain, direction: "out", errors };
|
|
348
|
+
}
|
|
349
|
+
var NAPPLET_TYPE_RE = /^[a-z0-9][a-z0-9._:-]*$/;
|
|
350
|
+
var AGGREGATE_HASH_RE = /^[0-9a-f]{64}$/;
|
|
351
|
+
var NON_EXECUTING_SCRIPT_TYPES = /* @__PURE__ */ new Set([
|
|
352
|
+
"application/json",
|
|
353
|
+
"application/ld+json",
|
|
354
|
+
"importmap",
|
|
355
|
+
"speculationrules"
|
|
356
|
+
]);
|
|
357
|
+
function readMeta(html, name) {
|
|
358
|
+
const tagRe = /<meta\b[^>]*>/gi;
|
|
359
|
+
let tag;
|
|
360
|
+
while ((tag = tagRe.exec(html)) !== null) {
|
|
361
|
+
const block = tag[0];
|
|
362
|
+
const nameMatch = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(block);
|
|
363
|
+
const metaName = (nameMatch?.[1] ?? nameMatch?.[2] ?? "").trim();
|
|
364
|
+
if (metaName !== name) continue;
|
|
365
|
+
const contentMatch = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(block);
|
|
366
|
+
return (contentMatch?.[1] ?? contentMatch?.[2] ?? "").trim();
|
|
367
|
+
}
|
|
368
|
+
return void 0;
|
|
369
|
+
}
|
|
370
|
+
function findInlineScripts(html) {
|
|
371
|
+
const stripped = html.replace(/<!--[\s\S]*?-->/g, "");
|
|
372
|
+
const scriptTagRe = /<script\b([\s\S]*?)>/gi;
|
|
373
|
+
const offenders = [];
|
|
374
|
+
let m;
|
|
375
|
+
while ((m = scriptTagRe.exec(stripped)) !== null) {
|
|
376
|
+
const tag = m[0];
|
|
377
|
+
const attrs = m[1] ?? "";
|
|
378
|
+
const srcMatch = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
|
|
379
|
+
if (srcMatch) {
|
|
380
|
+
const srcValue = (srcMatch[1] ?? srcMatch[2] ?? "").trim();
|
|
381
|
+
if (srcValue.length > 0) continue;
|
|
382
|
+
offenders.push(tag);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const typeMatch = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
|
|
386
|
+
const typeValue = (typeMatch?.[1] ?? typeMatch?.[2] ?? "").trim().toLowerCase();
|
|
387
|
+
if (typeValue && NON_EXECUTING_SCRIPT_TYPES.has(typeValue)) continue;
|
|
388
|
+
offenders.push(tag);
|
|
389
|
+
}
|
|
390
|
+
return offenders;
|
|
391
|
+
}
|
|
392
|
+
function usesPatternKeyword(node) {
|
|
393
|
+
if (Array.isArray(node)) return node.some(usesPatternKeyword);
|
|
394
|
+
if (node && typeof node === "object") {
|
|
395
|
+
const record = node;
|
|
396
|
+
if ("pattern" in record) return true;
|
|
397
|
+
return Object.values(record).some(usesPatternKeyword);
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
function validateManifest(html, options = {}) {
|
|
402
|
+
const requireAggregateHash = options.requireAggregateHash ?? true;
|
|
403
|
+
const errors = [];
|
|
404
|
+
const warnings = [];
|
|
405
|
+
const nappletType = readMeta(html, "napplet-type");
|
|
406
|
+
if (!nappletType) {
|
|
407
|
+
errors.push({ code: "missing-napplet-type", message: 'No <meta name="napplet-type"> declared' });
|
|
408
|
+
} else if (!NAPPLET_TYPE_RE.test(nappletType)) {
|
|
409
|
+
errors.push({ code: "invalid-napplet-type", message: `napplet-type "${nappletType}" must match ${NAPPLET_TYPE_RE}` });
|
|
410
|
+
}
|
|
411
|
+
const aggregateHash = readMeta(html, "napplet-aggregate-hash");
|
|
412
|
+
if (!aggregateHash) {
|
|
413
|
+
if (requireAggregateHash) {
|
|
414
|
+
errors.push({ code: "missing-aggregate-hash", message: 'No <meta name="napplet-aggregate-hash"> \u2014 the build plugin injects it; build before testing' });
|
|
415
|
+
}
|
|
416
|
+
} else if (!AGGREGATE_HASH_RE.test(aggregateHash)) {
|
|
417
|
+
errors.push({ code: "invalid-aggregate-hash", message: `napplet-aggregate-hash "${aggregateHash}" is not a 64-char lowercase hex digest` });
|
|
418
|
+
}
|
|
419
|
+
const requiresRaw = readMeta(html, "napplet-requires");
|
|
420
|
+
const requires = requiresRaw ? requiresRaw.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
421
|
+
for (const req of requires) {
|
|
422
|
+
const domain = req.startsWith("nap:") ? req.slice("nap:".length) : req;
|
|
423
|
+
if (!NAP_DOMAINS.includes(domain)) {
|
|
424
|
+
errors.push({ code: "unknown-required-nap", message: `napplet-requires lists "${req}" which is not a known NAP domain` });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const configSchemaRaw = readMeta(html, "napplet-config-schema");
|
|
428
|
+
if (configSchemaRaw) {
|
|
429
|
+
let parsed;
|
|
430
|
+
try {
|
|
431
|
+
parsed = JSON.parse(configSchemaRaw);
|
|
432
|
+
} catch {
|
|
433
|
+
parsed = void 0;
|
|
434
|
+
errors.push({ code: "invalid-config-schema", message: "napplet-config-schema is not valid JSON" });
|
|
435
|
+
}
|
|
436
|
+
if (parsed !== void 0) {
|
|
437
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
438
|
+
errors.push({ code: "invalid-config-schema", message: "napplet-config-schema must be a JSON Schema object" });
|
|
439
|
+
} else if (usesPatternKeyword(parsed)) {
|
|
440
|
+
errors.push({ code: "invalid-config-schema", message: "napplet-config-schema uses the `pattern` keyword, which the draft-07 core subset excludes (CVE-2025-69873)" });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const connectRaw = readMeta(html, "napplet-connect-requires");
|
|
445
|
+
const connectTokens = connectRaw ? connectRaw.split(/\s+/).map((s) => s.trim()).filter(Boolean) : [];
|
|
446
|
+
const connectOrigins = [];
|
|
447
|
+
for (const origin of connectTokens) {
|
|
448
|
+
try {
|
|
449
|
+
connectOrigins.push(normalizeConnectOrigin(origin));
|
|
450
|
+
} catch (err) {
|
|
451
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
452
|
+
errors.push({ code: "invalid-connect-origin", message: `connect origin "${origin}" is invalid: ${reason}` });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const offender of findInlineScripts(html)) {
|
|
456
|
+
errors.push({ code: "inline-script", message: `Inline <script> is forbidden under script-src 'self': ${offender}` });
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
ok: errors.length === 0,
|
|
460
|
+
nappletType,
|
|
461
|
+
aggregateHash,
|
|
462
|
+
requires,
|
|
463
|
+
connectOrigins,
|
|
464
|
+
errors,
|
|
465
|
+
warnings
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
var REFERENCE_PUBKEY = "f".repeat(64);
|
|
469
|
+
var REFERENCE_BLOB_URL = "https://reference.invalid/blob";
|
|
470
|
+
var ok = (v) => [v];
|
|
471
|
+
var none = () => [];
|
|
472
|
+
var RESPONDERS = {
|
|
473
|
+
// relay
|
|
474
|
+
"relay.subscribe": (e) => ok({ type: "relay.eose", subId: e.subId }),
|
|
475
|
+
"relay.close": none,
|
|
476
|
+
"relay.publish": (e) => ok({ type: "relay.publish.result", id: e.id, ok: true, event: e.event }),
|
|
477
|
+
"relay.publishEncrypted": (e) => ok({ type: "relay.publishEncrypted.result", id: e.id, ok: true, event: e.event }),
|
|
478
|
+
"relay.query": (e) => ok({ type: "relay.query.result", id: e.id, events: [] }),
|
|
479
|
+
// identity
|
|
480
|
+
"identity.getPublicKey": (e) => ok({ type: "identity.getPublicKey.result", id: e.id, pubkey: REFERENCE_PUBKEY }),
|
|
481
|
+
"identity.getRelays": (e) => ok({ type: "identity.getRelays.result", id: e.id, relays: {} }),
|
|
482
|
+
"identity.getProfile": (e) => ok({ type: "identity.getProfile.result", id: e.id, profile: null }),
|
|
483
|
+
"identity.getFollows": (e) => ok({ type: "identity.getFollows.result", id: e.id, pubkeys: [] }),
|
|
484
|
+
"identity.getList": (e) => ok({ type: "identity.getList.result", id: e.id, entries: [] }),
|
|
485
|
+
"identity.getZaps": (e) => ok({ type: "identity.getZaps.result", id: e.id, zaps: [] }),
|
|
486
|
+
"identity.getMutes": (e) => ok({ type: "identity.getMutes.result", id: e.id, pubkeys: [] }),
|
|
487
|
+
"identity.getBlocked": (e) => ok({ type: "identity.getBlocked.result", id: e.id, pubkeys: [] }),
|
|
488
|
+
"identity.getBadges": (e) => ok({ type: "identity.getBadges.result", id: e.id, badges: [] }),
|
|
489
|
+
// storage
|
|
490
|
+
"storage.get": (e) => ok({ type: "storage.get.result", id: e.id, value: null }),
|
|
491
|
+
"storage.set": (e) => ok({ type: "storage.set.result", id: e.id }),
|
|
492
|
+
"storage.remove": (e) => ok({ type: "storage.remove.result", id: e.id }),
|
|
493
|
+
"storage.keys": (e) => ok({ type: "storage.keys.result", id: e.id, keys: [] }),
|
|
494
|
+
// inc
|
|
495
|
+
"inc.emit": none,
|
|
496
|
+
"inc.subscribe": (e) => ok({ type: "inc.subscribe.result", id: e.id }),
|
|
497
|
+
"inc.unsubscribe": none,
|
|
498
|
+
"inc.channel.open": (e) => ok({ type: "inc.channel.open.result", id: e.id, channelId: `chan-${String(e.id)}`, peer: "reference-peer" }),
|
|
499
|
+
"inc.channel.emit": none,
|
|
500
|
+
"inc.channel.broadcast": none,
|
|
501
|
+
"inc.channel.list": (e) => ok({ type: "inc.channel.list.result", id: e.id, channels: [] }),
|
|
502
|
+
"inc.channel.close": none,
|
|
503
|
+
// theme
|
|
504
|
+
"theme.get": (e) => ok({ type: "theme.get.result", id: e.id, theme: { colors: {}, mode: "dark" } }),
|
|
505
|
+
// keys
|
|
506
|
+
"keys.forward": none,
|
|
507
|
+
"keys.registerAction": (e) => ok({ type: "keys.registerAction.result", id: e.id, actionId: `action-${String(e.id)}` }),
|
|
508
|
+
"keys.unregisterAction": none,
|
|
509
|
+
// media
|
|
510
|
+
"media.session.create": (e) => ok({ type: "media.session.create.result", id: e.id, sessionId: `session-${String(e.id)}`, owner: e.owner }),
|
|
511
|
+
"media.session.update": none,
|
|
512
|
+
"media.session.destroy": none,
|
|
513
|
+
"media.state": none,
|
|
514
|
+
"media.capabilities": none,
|
|
515
|
+
// notify
|
|
516
|
+
"notify.send": (e) => ok({ type: "notify.send.result", id: e.id, notificationId: `notif-${String(e.id)}` }),
|
|
517
|
+
"notify.dismiss": none,
|
|
518
|
+
"notify.badge": none,
|
|
519
|
+
"notify.channel.register": none,
|
|
520
|
+
"notify.permission.request": (e) => ok({ type: "notify.permission.result", id: e.id, granted: true }),
|
|
521
|
+
// config
|
|
522
|
+
"config.registerSchema": (e) => ok({ type: "config.registerSchema.result", id: e.id, ok: true }),
|
|
523
|
+
"config.get": (e) => ok({ type: "config.values", id: e.id, values: {} }),
|
|
524
|
+
"config.subscribe": () => ok({ type: "config.values", values: {} }),
|
|
525
|
+
"config.unsubscribe": none,
|
|
526
|
+
"config.openSettings": none,
|
|
527
|
+
// resource
|
|
528
|
+
"resource.bytes": (e) => ok({ type: "resource.bytes.result", id: e.id, blob: new Blob([]), mime: "application/octet-stream" }),
|
|
529
|
+
"resource.cancel": none,
|
|
530
|
+
// cvm
|
|
531
|
+
"cvm.discover": (e) => ok({ type: "cvm.discover.result", id: e.id, servers: [] }),
|
|
532
|
+
"cvm.request": (e) => ok({ type: "cvm.request.result", id: e.id, message: {} }),
|
|
533
|
+
"cvm.close": (e) => ok({ type: "cvm.close.result", id: e.id }),
|
|
534
|
+
// outbox
|
|
535
|
+
"outbox.query": (e) => ok({ type: "outbox.query.result", id: e.id, events: [], relays: {} }),
|
|
536
|
+
"outbox.subscribe": (e) => ok({ type: "outbox.eose", subId: e.subId }),
|
|
537
|
+
"outbox.close": none,
|
|
538
|
+
"outbox.publish": (e) => ok({ type: "outbox.publish.result", id: e.id, ok: true }),
|
|
539
|
+
"outbox.resolveRelays": (e) => ok({ type: "outbox.resolveRelays.result", id: e.id, plan: {} }),
|
|
540
|
+
// upload
|
|
541
|
+
"upload.upload": (e) => ok({ type: "upload.upload.result", id: e.id, result: { url: REFERENCE_BLOB_URL } }),
|
|
542
|
+
"upload.status": (e) => ok({ type: "upload.status.result", id: e.id, status: {} }),
|
|
543
|
+
// intent
|
|
544
|
+
"intent.invoke": (e) => ok({ type: "intent.invoke.result", id: e.id, result: {} }),
|
|
545
|
+
"intent.available": (e) => ok({ type: "intent.available.result", id: e.id, availability: {} }),
|
|
546
|
+
"intent.handlers": (e) => ok({ type: "intent.handlers.result", id: e.id, handlers: [] })
|
|
547
|
+
};
|
|
548
|
+
function createReferenceShell(options = {}) {
|
|
549
|
+
const now = options.now ?? (() => Date.now());
|
|
550
|
+
const capabilities = {
|
|
551
|
+
naps: options.capabilities?.naps ?? [...NAP_DOMAINS],
|
|
552
|
+
sandbox: options.capabilities?.sandbox ?? []
|
|
553
|
+
};
|
|
554
|
+
const records = [];
|
|
555
|
+
function handle(envelope) {
|
|
556
|
+
const type = envelope && typeof envelope === "object" && typeof envelope.type === "string" ? envelope.type : void 0;
|
|
557
|
+
if (type === "shell.ready") {
|
|
558
|
+
return [{ type: "shell.init", capabilities: { naps: capabilities.naps, sandbox: capabilities.sandbox } }];
|
|
559
|
+
}
|
|
560
|
+
records.push({ envelope, verdict: validateEnvelope(envelope), timestamp: now() });
|
|
561
|
+
if (!type) return [];
|
|
562
|
+
const responder = RESPONDERS[type];
|
|
563
|
+
return responder ? responder(envelope) : [];
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
capabilities,
|
|
567
|
+
get records() {
|
|
568
|
+
return records;
|
|
569
|
+
},
|
|
570
|
+
handle,
|
|
571
|
+
reset() {
|
|
572
|
+
records.length = 0;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function sleep(ms) {
|
|
577
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
578
|
+
}
|
|
579
|
+
async function bootOnce(caps, opts, doc, win) {
|
|
580
|
+
const shell = createReferenceShell(caps ? { capabilities: caps } : void 0);
|
|
581
|
+
const iframe = doc.createElement("iframe");
|
|
582
|
+
iframe.setAttribute("sandbox", opts.allowSameOrigin ? "allow-scripts allow-same-origin" : "allow-scripts");
|
|
583
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
584
|
+
iframe.style.position = "absolute";
|
|
585
|
+
iframe.style.width = "1px";
|
|
586
|
+
iframe.style.height = "1px";
|
|
587
|
+
iframe.style.left = "-9999px";
|
|
588
|
+
let resolveReady;
|
|
589
|
+
const readyPromise = new Promise((resolve) => {
|
|
590
|
+
resolveReady = resolve;
|
|
591
|
+
});
|
|
592
|
+
const listener = (event) => {
|
|
593
|
+
if (event.source !== iframe.contentWindow) return;
|
|
594
|
+
const responses = shell.handle(event.data);
|
|
595
|
+
const source = event.source;
|
|
596
|
+
for (const response of responses) source?.postMessage(response, "*");
|
|
597
|
+
const data = event.data;
|
|
598
|
+
if (data && typeof data === "object" && data.type === "shell.ready") resolveReady();
|
|
599
|
+
};
|
|
600
|
+
win.addEventListener("message", listener);
|
|
601
|
+
(doc.body ?? doc.documentElement).appendChild(iframe);
|
|
602
|
+
iframe.setAttribute("src", opts.url);
|
|
603
|
+
let installedGlobal = false;
|
|
604
|
+
let bootError = null;
|
|
605
|
+
const timedOut = /* @__PURE__ */ Symbol("timeout");
|
|
606
|
+
const race = await Promise.race([readyPromise.then(() => "ready"), sleep(opts.readyTimeoutMs).then(() => timedOut)]);
|
|
607
|
+
if (race === timedOut) {
|
|
608
|
+
bootError = `napplet did not boot (no shell.ready within ${opts.readyTimeoutMs}ms; a conformant napplet must run under sandbox="allow-scripts" without allow-same-origin)`;
|
|
609
|
+
} else {
|
|
610
|
+
installedGlobal = true;
|
|
611
|
+
await sleep(opts.settleMs);
|
|
612
|
+
}
|
|
613
|
+
win.removeEventListener("message", listener);
|
|
614
|
+
try {
|
|
615
|
+
iframe.remove();
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
return { installedGlobal, bootError, emitted: [...shell.records] };
|
|
619
|
+
}
|
|
620
|
+
async function bootAndCollect(options) {
|
|
621
|
+
const doc = options.document ?? document;
|
|
622
|
+
const win = options.window ?? window;
|
|
623
|
+
const resolved = {
|
|
624
|
+
url: options.url,
|
|
625
|
+
readyTimeoutMs: options.readyTimeoutMs ?? 5e3,
|
|
626
|
+
settleMs: options.settleMs ?? 600,
|
|
627
|
+
allowSameOrigin: options.allowSameOrigin ?? false
|
|
628
|
+
};
|
|
629
|
+
const primary = await bootOnce(void 0, resolved, doc, win);
|
|
630
|
+
let degraded = null;
|
|
631
|
+
if (options.runDegraded ?? true) {
|
|
632
|
+
const d = await bootOnce({ naps: [], sandbox: [] }, resolved, doc, win);
|
|
633
|
+
degraded = { bootError: d.bootError, emitted: d.emitted };
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
installedGlobal: primary.installedGlobal,
|
|
637
|
+
bootError: primary.bootError,
|
|
638
|
+
emitted: primary.emitted,
|
|
639
|
+
degraded
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
var pass = (detail) => ({ status: "pass", detail });
|
|
643
|
+
var fail = (detail, diagnostics) => ({ status: "fail", detail, diagnostics });
|
|
644
|
+
var skip = (detail) => ({ status: "skip", detail });
|
|
645
|
+
var result = { pass, fail, skip };
|
|
646
|
+
function manifestErrors(html, codes) {
|
|
647
|
+
return validateManifest(html).errors.filter((e) => codes.includes(e.code));
|
|
648
|
+
}
|
|
649
|
+
function hasMeta(html, name) {
|
|
650
|
+
return new RegExp(`name\\s*=\\s*["']${name}["']`, "i").test(html);
|
|
651
|
+
}
|
|
652
|
+
function manifestCheck(id, title, codes, skipMeta) {
|
|
653
|
+
return {
|
|
654
|
+
id,
|
|
655
|
+
area: "manifest",
|
|
656
|
+
severity: "error",
|
|
657
|
+
title,
|
|
658
|
+
run: (ctx) => {
|
|
659
|
+
if (skipMeta && !hasMeta(ctx.manifestHtml, skipMeta)) return result.skip(`No ${skipMeta} declared`);
|
|
660
|
+
const errs = manifestErrors(ctx.manifestHtml, codes);
|
|
661
|
+
return errs.length ? result.fail(errs[0].message, errs) : result.pass();
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
var CHECKS = [
|
|
666
|
+
// ── manifest ───────────────────────────────────────────────────────────────
|
|
667
|
+
manifestCheck("manifest/napplet-type", "Declares a valid napplet-type", ["missing-napplet-type", "invalid-napplet-type"]),
|
|
668
|
+
manifestCheck("manifest/aggregate-hash", "Build injected a valid aggregate hash", ["missing-aggregate-hash", "invalid-aggregate-hash"]),
|
|
669
|
+
manifestCheck("manifest/declared-naps", "napplet-requires lists only real NAP domains", ["unknown-required-nap"], "napplet-requires"),
|
|
670
|
+
manifestCheck("manifest/config-schema", "Config schema is a draft-07 core subset", ["invalid-config-schema"], "napplet-config-schema"),
|
|
671
|
+
manifestCheck("manifest/connect-origins", "Declared connect origins are valid", ["invalid-connect-origin"], "napplet-connect-requires"),
|
|
672
|
+
{
|
|
673
|
+
id: "manifest/no-inline-scripts",
|
|
674
|
+
area: "manifest",
|
|
675
|
+
severity: "error",
|
|
676
|
+
title: "No inline <script> (incompatible with script-src 'self')",
|
|
677
|
+
run: (ctx) => {
|
|
678
|
+
const errs = manifestErrors(ctx.manifestHtml, ["inline-script"]);
|
|
679
|
+
return errs.length ? result.fail(`${errs.length} inline script(s) found`, errs) : result.pass();
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
// ── boot ─────────────────────────────────────────────────────────────────────
|
|
683
|
+
{
|
|
684
|
+
id: "boot/sandbox-allow-scripts",
|
|
685
|
+
area: "boot",
|
|
686
|
+
severity: "error",
|
|
687
|
+
title: "Runs under allow-scripts without allow-same-origin",
|
|
688
|
+
run: (ctx) => {
|
|
689
|
+
if (!ctx.sandbox.allowScripts) return result.fail("Napplet was not loaded with allow-scripts");
|
|
690
|
+
if (ctx.sandbox.allowSameOrigin) return result.fail("Napplet relied on allow-same-origin, which a shell must not grant");
|
|
691
|
+
return result.pass();
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
id: "boot/installs-global",
|
|
696
|
+
area: "boot",
|
|
697
|
+
severity: "error",
|
|
698
|
+
title: "Installs window.napplet",
|
|
699
|
+
run: (ctx) => ctx.installedGlobal ? result.pass() : result.fail("window.napplet was not present after load")
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: "boot/no-boot-error",
|
|
703
|
+
area: "boot",
|
|
704
|
+
severity: "error",
|
|
705
|
+
title: "Boots without an uncaught error",
|
|
706
|
+
run: (ctx) => ctx.bootError ? result.fail(ctx.bootError) : result.pass()
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
id: "boot/no-forbidden-globals",
|
|
710
|
+
area: "boot",
|
|
711
|
+
severity: "error",
|
|
712
|
+
title: "Does not access forbidden globals (e.g. window.nostr)",
|
|
713
|
+
run: (ctx) => ctx.forbiddenGlobals.length ? result.fail(`Accessed forbidden global(s): ${ctx.forbiddenGlobals.join(", ")}`, ctx.forbiddenGlobals) : result.pass()
|
|
714
|
+
},
|
|
715
|
+
// ── wire ─────────────────────────────────────────────────────────────────────
|
|
716
|
+
{
|
|
717
|
+
id: "wire/envelope-well-formed",
|
|
718
|
+
area: "wire",
|
|
719
|
+
severity: "error",
|
|
720
|
+
title: "Every emitted envelope is well-formed",
|
|
721
|
+
run: (ctx) => {
|
|
722
|
+
if (ctx.emitted.length === 0) return result.skip("Napplet emitted no envelopes");
|
|
723
|
+
const bad = ctx.emitted.filter((r) => !r.verdict.ok);
|
|
724
|
+
if (bad.length === 0) return result.pass(`${ctx.emitted.length} envelope(s) valid`);
|
|
725
|
+
const diag = bad.map((r) => ({ envelope: r.envelope, errors: r.verdict.errors }));
|
|
726
|
+
return result.fail(`${bad.length}/${ctx.emitted.length} emitted envelope(s) invalid`, diag);
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
id: "wire/declared-naps-only",
|
|
731
|
+
area: "wire",
|
|
732
|
+
severity: "warning",
|
|
733
|
+
title: "Only emits NAP domains it declared in napplet-requires",
|
|
734
|
+
run: (ctx) => {
|
|
735
|
+
if (ctx.emitted.length === 0) return result.skip("Napplet emitted no envelopes");
|
|
736
|
+
if (!hasMeta(ctx.manifestHtml, "napplet-requires")) return result.skip("No napplet-requires to compare against");
|
|
737
|
+
const declared = new Set(validateManifest(ctx.manifestHtml).requires.map((r) => r.startsWith("nap:") ? r.slice(4) : r));
|
|
738
|
+
const used = new Set(
|
|
739
|
+
ctx.emitted.map((r) => r.verdict.domain).filter((d) => typeof d === "string")
|
|
740
|
+
);
|
|
741
|
+
const undeclared = [...used].filter((d) => !declared.has(d));
|
|
742
|
+
return undeclared.length ? result.fail(`Emitted undeclared NAP domain(s): ${undeclared.join(", ")}`, undeclared) : result.pass();
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
// ── degradation ───────────────────────────────────────────────────────────────
|
|
746
|
+
{
|
|
747
|
+
id: "degrade/supports-false",
|
|
748
|
+
area: "degradation",
|
|
749
|
+
severity: "error",
|
|
750
|
+
title: "Does not crash when shell.supports() is false",
|
|
751
|
+
run: (ctx) => {
|
|
752
|
+
if (!ctx.degraded) return result.skip("Host did not run the degraded (no-capabilities) pass");
|
|
753
|
+
return ctx.degraded.bootError ? result.fail(`Crashed under a no-capability shell: ${ctx.degraded.bootError}`) : result.pass();
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
// ── lifecycle ─────────────────────────────────────────────────────────────────
|
|
757
|
+
{
|
|
758
|
+
id: "lifecycle/clean-teardown",
|
|
759
|
+
area: "lifecycle",
|
|
760
|
+
severity: "warning",
|
|
761
|
+
title: "Tears down listeners on unload",
|
|
762
|
+
run: (ctx) => {
|
|
763
|
+
if (!ctx.lifecycle) return result.skip("Lifecycle not measured");
|
|
764
|
+
return ctx.lifecycle.listenerLeak ? result.fail("Message listeners remained after unload") : result.pass();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
];
|
|
768
|
+
|
|
769
|
+
// src/host-entry.ts
|
|
770
|
+
window.NappletConformanceHost = {
|
|
771
|
+
run(opts) {
|
|
772
|
+
return bootAndCollect(opts);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
})();
|