@napplet/conformance-cli 0.1.1 → 0.2.1
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
var NappletConformanceHostBundle = (() => {
|
|
3
3
|
// ../core/dist/index.js
|
|
4
|
-
var NAP_DOMAINS = ["relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "
|
|
4
|
+
var NAP_DOMAINS = ["relay", "identity", "storage", "inc", "theme", "keys", "media", "notify", "config", "resource", "cvm", "outbox", "upload", "intent"];
|
|
5
5
|
function createDispatch() {
|
|
6
6
|
const handlers = /* @__PURE__ */ new Map();
|
|
7
7
|
function registerNap2(domain, handler) {
|
|
@@ -29,122 +29,15 @@ var NappletConformanceHostBundle = (() => {
|
|
|
29
29
|
var dispatch = _default.dispatch;
|
|
30
30
|
var getRegisteredDomains = _default.getRegisteredDomains;
|
|
31
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
32
|
// ../conformance/dist/index.js
|
|
33
|
+
var FOUNDATIONAL_DOMAINS = ["shell"];
|
|
146
34
|
var ID = { id: "string" };
|
|
147
35
|
var ENVELOPE_SPECS = {
|
|
36
|
+
// ── shell (NAP-SHELL — foundational handshake) ─────────────────────────────
|
|
37
|
+
// `shell.ready` is a bare liveness ping (no required fields); `shell.init` is
|
|
38
|
+
// the runtime's environment reply (inbound — a napplet must not emit it).
|
|
39
|
+
"shell.ready": { dir: "out" },
|
|
40
|
+
"shell.init": { dir: "in" },
|
|
148
41
|
// ── relay ────────────────────────────────────────────────────────────────
|
|
149
42
|
"relay.subscribe": { dir: "out", fields: { ...ID, subId: "string", filters: "array" } },
|
|
150
43
|
"relay.close": { dir: "out", fields: { ...ID, subId: "string" } },
|
|
@@ -250,8 +143,6 @@ var NappletConformanceHostBundle = (() => {
|
|
|
250
143
|
"resource.cancel": { dir: "out", fields: { ...ID } },
|
|
251
144
|
"resource.bytes.result": { dir: "in" },
|
|
252
145
|
"resource.bytes.error": { dir: "in" },
|
|
253
|
-
// ── class (shell-initiated only) ─────────────────────────────────────────
|
|
254
|
-
"class.assigned": { dir: "in" },
|
|
255
146
|
// ── cvm ──────────────────────────────────────────────────────────────────
|
|
256
147
|
"cvm.discover": { dir: "out", fields: { ...ID } },
|
|
257
148
|
"cvm.request": { dir: "out", fields: { ...ID, server: "object", message: "object" } },
|
|
@@ -315,7 +206,8 @@ var NappletConformanceHostBundle = (() => {
|
|
|
315
206
|
return { ok: false, type, errors: [{ code: "malformed-type", message: `Envelope type "${type}" is not in domain.action form` }] };
|
|
316
207
|
}
|
|
317
208
|
const domain = type.slice(0, dotIndex);
|
|
318
|
-
|
|
209
|
+
const isKnownDomain = NAP_DOMAINS.includes(domain) || FOUNDATIONAL_DOMAINS.includes(domain);
|
|
210
|
+
if (!isKnownDomain) {
|
|
319
211
|
return { ok: false, type, domain, errors: [{ code: "unknown-domain", message: `"${domain}" is not a known NAP domain` }] };
|
|
320
212
|
}
|
|
321
213
|
const spec = ENVELOPE_SPECS[type];
|
|
@@ -347,12 +239,6 @@ var NappletConformanceHostBundle = (() => {
|
|
|
347
239
|
return { ok: errors.length === 0, type, domain, direction: "out", errors };
|
|
348
240
|
}
|
|
349
241
|
var NAPPLET_TYPE_RE = /^[a-z0-9][a-z0-9._:-]*$/;
|
|
350
|
-
var NON_EXECUTING_SCRIPT_TYPES = /* @__PURE__ */ new Set([
|
|
351
|
-
"application/json",
|
|
352
|
-
"application/ld+json",
|
|
353
|
-
"importmap",
|
|
354
|
-
"speculationrules"
|
|
355
|
-
]);
|
|
356
242
|
function decodeHtmlEntities(value) {
|
|
357
243
|
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
|
|
358
244
|
if (entity[0] === "#") {
|
|
@@ -376,28 +262,6 @@ var NappletConformanceHostBundle = (() => {
|
|
|
376
262
|
}
|
|
377
263
|
return void 0;
|
|
378
264
|
}
|
|
379
|
-
function findInlineScripts(html) {
|
|
380
|
-
const stripped = html.replace(/<!--[\s\S]*?-->/g, "");
|
|
381
|
-
const scriptTagRe = /<script\b([\s\S]*?)>/gi;
|
|
382
|
-
const offenders = [];
|
|
383
|
-
let m;
|
|
384
|
-
while ((m = scriptTagRe.exec(stripped)) !== null) {
|
|
385
|
-
const tag = m[0];
|
|
386
|
-
const attrs = m[1] ?? "";
|
|
387
|
-
const srcMatch = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
|
|
388
|
-
if (srcMatch) {
|
|
389
|
-
const srcValue = (srcMatch[1] ?? srcMatch[2] ?? "").trim();
|
|
390
|
-
if (srcValue.length > 0) continue;
|
|
391
|
-
offenders.push(tag);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
const typeMatch = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
|
|
395
|
-
const typeValue = (typeMatch?.[1] ?? typeMatch?.[2] ?? "").trim().toLowerCase();
|
|
396
|
-
if (typeValue && NON_EXECUTING_SCRIPT_TYPES.has(typeValue)) continue;
|
|
397
|
-
offenders.push(tag);
|
|
398
|
-
}
|
|
399
|
-
return offenders;
|
|
400
|
-
}
|
|
401
265
|
function usesPatternKeyword(node) {
|
|
402
266
|
if (Array.isArray(node)) return node.some(usesPatternKeyword);
|
|
403
267
|
if (node && typeof node === "object") {
|
|
@@ -441,25 +305,10 @@ var NappletConformanceHostBundle = (() => {
|
|
|
441
305
|
}
|
|
442
306
|
}
|
|
443
307
|
}
|
|
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
308
|
return {
|
|
459
309
|
ok: errors.length === 0,
|
|
460
310
|
nappletType,
|
|
461
311
|
requires,
|
|
462
|
-
connectOrigins,
|
|
463
312
|
errors,
|
|
464
313
|
warnings
|
|
465
314
|
};
|
|
@@ -547,14 +396,23 @@ var NappletConformanceHostBundle = (() => {
|
|
|
547
396
|
function createReferenceShell(options = {}) {
|
|
548
397
|
const now = options.now ?? (() => Date.now());
|
|
549
398
|
const capabilities = {
|
|
550
|
-
|
|
551
|
-
|
|
399
|
+
domains: options.capabilities?.domains ?? [...NAP_DOMAINS],
|
|
400
|
+
protocols: options.capabilities?.protocols ?? {}
|
|
552
401
|
};
|
|
402
|
+
const services = options.services ?? [];
|
|
403
|
+
const klass = options.class ?? null;
|
|
553
404
|
const records = [];
|
|
554
405
|
function handle(envelope) {
|
|
555
406
|
const type = envelope && typeof envelope === "object" && typeof envelope.type === "string" ? envelope.type : void 0;
|
|
556
407
|
if (type === "shell.ready") {
|
|
557
|
-
return [
|
|
408
|
+
return [
|
|
409
|
+
{
|
|
410
|
+
type: "shell.init",
|
|
411
|
+
capabilities: { domains: capabilities.domains, protocols: capabilities.protocols },
|
|
412
|
+
services,
|
|
413
|
+
class: klass
|
|
414
|
+
}
|
|
415
|
+
];
|
|
558
416
|
}
|
|
559
417
|
records.push({ envelope, verdict: validateEnvelope(envelope), timestamp: now() });
|
|
560
418
|
if (!type) return [];
|
|
@@ -563,6 +421,8 @@ var NappletConformanceHostBundle = (() => {
|
|
|
563
421
|
}
|
|
564
422
|
return {
|
|
565
423
|
capabilities,
|
|
424
|
+
services,
|
|
425
|
+
class: klass,
|
|
566
426
|
get records() {
|
|
567
427
|
return records;
|
|
568
428
|
},
|
|
@@ -628,7 +488,7 @@ var NappletConformanceHostBundle = (() => {
|
|
|
628
488
|
const primary = await bootOnce(void 0, resolved, doc, win);
|
|
629
489
|
let degraded = null;
|
|
630
490
|
if (options.runDegraded ?? true) {
|
|
631
|
-
const d = await bootOnce({
|
|
491
|
+
const d = await bootOnce({ domains: [], protocols: {} }, resolved, doc, win);
|
|
632
492
|
degraded = { bootError: d.bootError, emitted: d.emitted };
|
|
633
493
|
}
|
|
634
494
|
return {
|
|
@@ -666,17 +526,6 @@ var NappletConformanceHostBundle = (() => {
|
|
|
666
526
|
manifestCheck("manifest/napplet-type", "Declares a valid napplet-type", ["missing-napplet-type", "invalid-napplet-type"]),
|
|
667
527
|
manifestCheck("manifest/declared-naps", "napplet-requires lists only real NAP domains", ["unknown-required-nap"], "napplet-requires"),
|
|
668
528
|
manifestCheck("manifest/config-schema", "Config schema is a draft-07 core subset", ["invalid-config-schema"], "napplet-config-schema"),
|
|
669
|
-
manifestCheck("manifest/connect-origins", "Declared connect origins are valid", ["invalid-connect-origin"], "napplet-connect-requires"),
|
|
670
|
-
{
|
|
671
|
-
id: "manifest/no-inline-scripts",
|
|
672
|
-
area: "manifest",
|
|
673
|
-
severity: "error",
|
|
674
|
-
title: "No inline <script> (incompatible with script-src 'self')",
|
|
675
|
-
run: (ctx) => {
|
|
676
|
-
const errs = manifestErrors(ctx.manifestHtml, ["inline-script"]);
|
|
677
|
-
return errs.length ? result.fail(`${errs.length} inline script(s) found`, errs) : result.pass();
|
|
678
|
-
}
|
|
679
|
-
},
|
|
680
529
|
// ── boot ─────────────────────────────────────────────────────────────────────
|
|
681
530
|
{
|
|
682
531
|
id: "boot/sandbox-allow-scripts",
|
|
@@ -693,14 +542,18 @@ var NappletConformanceHostBundle = (() => {
|
|
|
693
542
|
id: "boot/installs-global",
|
|
694
543
|
area: "boot",
|
|
695
544
|
severity: "error",
|
|
696
|
-
|
|
697
|
-
|
|
545
|
+
// NAP-SHELL: the observable boot signal across the opaque sandbox is the
|
|
546
|
+
// shim posting `shell.ready`. Receiving it proves window.napplet installed.
|
|
547
|
+
title: "Completes the NAP-SHELL handshake (posts shell.ready)",
|
|
548
|
+
run: (ctx) => ctx.installedGlobal ? result.pass() : result.fail("window.napplet was not present after load (no NAP-SHELL shell.ready)")
|
|
698
549
|
},
|
|
699
550
|
{
|
|
700
551
|
id: "boot/no-boot-error",
|
|
701
552
|
area: "boot",
|
|
702
553
|
severity: "error",
|
|
703
|
-
|
|
554
|
+
// Boot is observed via the NAP-SHELL `shell.ready` signal; its absence within
|
|
555
|
+
// the timeout (or a crash before it) surfaces here as a boot error.
|
|
556
|
+
title: "Boots without an uncaught error (observed via NAP-SHELL shell.ready)",
|
|
704
557
|
run: (ctx) => ctx.bootError ? result.fail(ctx.bootError) : result.pass()
|
|
705
558
|
},
|
|
706
559
|
{
|
|
@@ -745,7 +598,10 @@ var NappletConformanceHostBundle = (() => {
|
|
|
745
598
|
id: "degrade/supports-false",
|
|
746
599
|
area: "degradation",
|
|
747
600
|
severity: "error",
|
|
748
|
-
|
|
601
|
+
// NAP-SHELL: when the runtime advertises an empty environment (no domains/
|
|
602
|
+
// protocols), every shell.supports() returns false; a conformant napplet must
|
|
603
|
+
// degrade rather than crash.
|
|
604
|
+
title: "Does not crash when the NAP-SHELL environment offers no capabilities (supports() false)",
|
|
749
605
|
run: (ctx) => {
|
|
750
606
|
if (!ctx.degraded) return result.skip("Host did not run the degraded (no-capabilities) pass");
|
|
751
607
|
return ctx.degraded.bootError ? result.fail(`Crashed under a no-capability shell: ${ctx.degraded.bootError}`) : result.pass();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function s(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function o(i){if(i.ep)return;i.ep=!0;const r=s(i);fetch(i.href,r)}})();var I=["relay","identity","storage","inc","theme","keys","media","notify","config","resource","cvm","outbox","upload","intent"],_=["shell"],a={id:"string"},G={"shell.ready":{dir:"out"},"shell.init":{dir:"in"},"relay.subscribe":{dir:"out",fields:{...a,subId:"string",filters:"array"}},"relay.close":{dir:"out",fields:{...a,subId:"string"}},"relay.publish":{dir:"out",fields:{...a,event:"object"}},"relay.publishEncrypted":{dir:"out",fields:{...a,event:"object",recipient:"string"}},"relay.query":{dir:"out",fields:{...a,filters:"array"}},"relay.event":{dir:"in"},"relay.eose":{dir:"in"},"relay.closed":{dir:"in"},"relay.publish.result":{dir:"in"},"relay.publishEncrypted.result":{dir:"in"},"relay.query.result":{dir:"in"},"identity.getPublicKey":{dir:"out",fields:{...a}},"identity.getRelays":{dir:"out",fields:{...a}},"identity.getProfile":{dir:"out",fields:{...a}},"identity.getFollows":{dir:"out",fields:{...a}},"identity.getList":{dir:"out",fields:{...a,listType:"string"}},"identity.getZaps":{dir:"out",fields:{...a}},"identity.getMutes":{dir:"out",fields:{...a}},"identity.getBlocked":{dir:"out",fields:{...a}},"identity.getBadges":{dir:"out",fields:{...a}},"identity.changed":{dir:"in"},"identity.getPublicKey.result":{dir:"in"},"identity.getRelays.result":{dir:"in"},"identity.getProfile.result":{dir:"in"},"identity.getFollows.result":{dir:"in"},"identity.getList.result":{dir:"in"},"identity.getZaps.result":{dir:"in"},"identity.getMutes.result":{dir:"in"},"identity.getBlocked.result":{dir:"in"},"identity.getBadges.result":{dir:"in"},"storage.get":{dir:"out",fields:{...a,key:"string"}},"storage.set":{dir:"out",fields:{...a,key:"string",value:"string"}},"storage.remove":{dir:"out",fields:{...a,key:"string"}},"storage.keys":{dir:"out",fields:{...a}},"storage.get.result":{dir:"in"},"storage.set.result":{dir:"in"},"storage.remove.result":{dir:"in"},"storage.keys.result":{dir:"in"},"inc.emit":{dir:"out",fields:{topic:"string"}},"inc.subscribe":{dir:"out",fields:{...a,topic:"string"}},"inc.unsubscribe":{dir:"out",fields:{topic:"string"}},"inc.channel.open":{dir:"out",fields:{...a,target:"string"}},"inc.channel.emit":{dir:"out",fields:{channelId:"string"}},"inc.channel.broadcast":{dir:"out"},"inc.channel.list":{dir:"out",fields:{...a}},"inc.channel.close":{dir:"out",fields:{channelId:"string"}},"inc.subscribe.result":{dir:"in"},"inc.event":{dir:"in"},"inc.channel.open.result":{dir:"in"},"inc.channel.event":{dir:"in"},"inc.channel.list.result":{dir:"in"},"inc.channel.closed":{dir:"in"},"theme.get":{dir:"out",fields:{...a}},"theme.get.result":{dir:"in"},"theme.changed":{dir:"in"},"keys.forward":{dir:"out",fields:{key:"string",code:"string",ctrl:"boolean",alt:"boolean",shift:"boolean",meta:"boolean"}},"keys.registerAction":{dir:"out",fields:{...a,action:"object"}},"keys.unregisterAction":{dir:"out",fields:{actionId:"string"}},"keys.registerAction.result":{dir:"in"},"keys.bindings":{dir:"in"},"keys.action":{dir:"in"},"media.session.create":{dir:"out",fields:{...a,owner:"object"}},"media.session.update":{dir:"out",fields:{sessionId:"string",metadata:"object"}},"media.session.destroy":{dir:"out",fields:{sessionId:"string"}},"media.state":{dir:"out",fields:{sessionId:"string",status:"string"}},"media.capabilities":{dir:"out",fields:{sessionId:"string",actions:"array"}},"media.session.create.result":{dir:"in"},"media.command":{dir:"in"},"media.controls":{dir:"in"},"notify.send":{dir:"out",fields:{...a,title:"string"}},"notify.dismiss":{dir:"out",fields:{notificationId:"string"}},"notify.badge":{dir:"out",fields:{count:"number"}},"notify.channel.register":{dir:"out",fields:{channelId:"string",label:"string"}},"notify.permission.request":{dir:"out",fields:{...a}},"notify.send.result":{dir:"in"},"notify.permission.result":{dir:"in"},"notify.action":{dir:"in"},"notify.clicked":{dir:"in"},"notify.dismissed":{dir:"in"},"notify.controls":{dir:"in"},"config.registerSchema":{dir:"out",fields:{...a,schema:"object"}},"config.get":{dir:"out",fields:{...a}},"config.subscribe":{dir:"out"},"config.unsubscribe":{dir:"out"},"config.openSettings":{dir:"out"},"config.registerSchema.result":{dir:"in"},"config.values":{dir:"in"},"config.schemaError":{dir:"in"},"resource.bytes":{dir:"out",fields:{...a,url:"string"}},"resource.cancel":{dir:"out",fields:{...a}},"resource.bytes.result":{dir:"in"},"resource.bytes.error":{dir:"in"},"cvm.discover":{dir:"out",fields:{...a}},"cvm.request":{dir:"out",fields:{...a,server:"object",message:"object"}},"cvm.close":{dir:"out",fields:{...a,server:"object"}},"cvm.discover.result":{dir:"in"},"cvm.request.result":{dir:"in"},"cvm.close.result":{dir:"in"},"cvm.event":{dir:"in"},"outbox.query":{dir:"out",fields:{...a,filters:"present"}},"outbox.subscribe":{dir:"out",fields:{...a,subId:"string",filters:"present"}},"outbox.close":{dir:"out",fields:{...a,subId:"string"}},"outbox.publish":{dir:"out",fields:{...a,event:"object"}},"outbox.resolveRelays":{dir:"out",fields:{...a,target:"present"}},"outbox.query.result":{dir:"in"},"outbox.event":{dir:"in"},"outbox.eose":{dir:"in"},"outbox.closed":{dir:"in"},"outbox.publish.result":{dir:"in"},"outbox.resolveRelays.result":{dir:"in"},"upload.upload":{dir:"out",fields:{...a,request:"object"}},"upload.status":{dir:"out",fields:{...a,uploadId:"string"}},"upload.upload.result":{dir:"in"},"upload.status.result":{dir:"in"},"upload.status.changed":{dir:"in"},"intent.invoke":{dir:"out",fields:{...a,request:"object"}},"intent.available":{dir:"out",fields:{...a,archetype:"string"}},"intent.handlers":{dir:"out",fields:{...a}},"intent.invoke.result":{dir:"in"},"intent.available.result":{dir:"in"},"intent.handlers.result":{dir:"in"},"intent.changed":{dir:"in"}};function B(e){if(e===void 0)return"undefined";if(e===null)return"null";if(Array.isArray(e))return"array";const t=typeof e;return t==="string"||t==="number"||t==="boolean"?t:t==="object"?"object":"present"}function K(e,t){return t==="present"?e!=null:B(e)===t}function U(e){const t=[];if(typeof e!="object"||e===null||Array.isArray(e))return{ok:!1,errors:[{code:"not-an-object",message:"Envelope must be a non-null object"}]};const s=e,o=s.type;if(typeof o!="string")return{ok:!1,errors:[{code:"missing-type",message:"Envelope is missing a string `type` field"}]};const i=o.indexOf(".");if(i<=0)return{ok:!1,type:o,errors:[{code:"malformed-type",message:`Envelope type "${o}" is not in domain.action form`}]};const r=o.slice(0,i);if(!(I.includes(r)||_.includes(r)))return{ok:!1,type:o,domain:r,errors:[{code:"unknown-domain",message:`"${r}" is not a known NAP domain`}]};const c=G[o];if(!c)return{ok:!1,type:o,domain:r,errors:[{code:"unknown-type",message:`"${o}" is not a known ${r} message type`}]};if(c.dir==="in")return{ok:!1,type:o,domain:r,direction:"in",errors:[{code:"inbound-type-emitted",message:`"${o}" is a shell→napplet message; a napplet must not emit it`}]};for(const[n,f]of Object.entries(c.fields??{})){if(!(n in s)||s[n]===void 0){t.push({code:"missing-field",message:`Required field "${n}" is missing`,field:n});continue}K(s[n],f)||t.push({code:"wrong-type",message:`Field "${n}" should be ${f} but is ${B(s[n])}`,field:n})}return{ok:t.length===0,type:o,domain:r,direction:"out",errors:t}}var R=/^[a-z0-9][a-z0-9._:-]*$/;function Z(e){return e.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g,(t,s)=>{if(s[0]==="#"){const i=s[1]==="x"||s[1]==="X"?parseInt(s.slice(2),16):parseInt(s.slice(1),10);return Number.isFinite(i)?String.fromCodePoint(i):t}return{amp:"&",lt:"<",gt:">",quot:'"',apos:"'"}[s]??t})}function S(e,t){const s=/<meta\b[^>]*>/gi;let o;for(;(o=s.exec(e))!==null;){const i=o[0],r=/\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(i);if((r?.[1]??r?.[2]??"").trim()!==t)continue;const c=/\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(i);return Z((c?.[1]??c?.[2]??"").trim())}}function L(e){if(Array.isArray(e))return e.some(L);if(e&&typeof e=="object"){const t=e;return"pattern"in t?!0:Object.values(t).some(L)}return!1}function k(e,t={}){const s=[],o=[],i=S(e,"napplet-type");i?R.test(i)||s.push({code:"invalid-napplet-type",message:`napplet-type "${i}" must match ${R}`}):s.push({code:"missing-napplet-type",message:'No <meta name="napplet-type"> declared'});const r=S(e,"napplet-requires"),d=r?r.split(",").map(n=>n.trim()).filter(Boolean):[];for(const n of d){const f=n.startsWith("nap:")?n.slice(4):n;I.includes(f)||s.push({code:"unknown-required-nap",message:`napplet-requires lists "${n}" which is not a known NAP domain`})}const c=S(e,"napplet-config-schema");if(c){let n;try{n=JSON.parse(c)}catch{n=void 0,s.push({code:"invalid-config-schema",message:"napplet-config-schema is not valid JSON"})}n!==void 0&&(typeof n!="object"||n===null||Array.isArray(n)?s.push({code:"invalid-config-schema",message:"napplet-config-schema must be a JSON Schema object"}):L(n)&&s.push({code:"invalid-config-schema",message:"napplet-config-schema uses the `pattern` keyword, which the draft-07 core subset excludes (CVE-2025-69873)"}))}return{ok:s.length===0,nappletType:i,requires:d,errors:s,warnings:o}}var z="f".repeat(64),J="https://reference.invalid/blob",l=e=>[e],p=()=>[],V={"relay.subscribe":e=>l({type:"relay.eose",subId:e.subId}),"relay.close":p,"relay.publish":e=>l({type:"relay.publish.result",id:e.id,ok:!0,event:e.event}),"relay.publishEncrypted":e=>l({type:"relay.publishEncrypted.result",id:e.id,ok:!0,event:e.event}),"relay.query":e=>l({type:"relay.query.result",id:e.id,events:[]}),"identity.getPublicKey":e=>l({type:"identity.getPublicKey.result",id:e.id,pubkey:z}),"identity.getRelays":e=>l({type:"identity.getRelays.result",id:e.id,relays:{}}),"identity.getProfile":e=>l({type:"identity.getProfile.result",id:e.id,profile:null}),"identity.getFollows":e=>l({type:"identity.getFollows.result",id:e.id,pubkeys:[]}),"identity.getList":e=>l({type:"identity.getList.result",id:e.id,entries:[]}),"identity.getZaps":e=>l({type:"identity.getZaps.result",id:e.id,zaps:[]}),"identity.getMutes":e=>l({type:"identity.getMutes.result",id:e.id,pubkeys:[]}),"identity.getBlocked":e=>l({type:"identity.getBlocked.result",id:e.id,pubkeys:[]}),"identity.getBadges":e=>l({type:"identity.getBadges.result",id:e.id,badges:[]}),"storage.get":e=>l({type:"storage.get.result",id:e.id,value:null}),"storage.set":e=>l({type:"storage.set.result",id:e.id}),"storage.remove":e=>l({type:"storage.remove.result",id:e.id}),"storage.keys":e=>l({type:"storage.keys.result",id:e.id,keys:[]}),"inc.emit":p,"inc.subscribe":e=>l({type:"inc.subscribe.result",id:e.id}),"inc.unsubscribe":p,"inc.channel.open":e=>l({type:"inc.channel.open.result",id:e.id,channelId:`chan-${String(e.id)}`,peer:"reference-peer"}),"inc.channel.emit":p,"inc.channel.broadcast":p,"inc.channel.list":e=>l({type:"inc.channel.list.result",id:e.id,channels:[]}),"inc.channel.close":p,"theme.get":e=>l({type:"theme.get.result",id:e.id,theme:{colors:{},mode:"dark"}}),"keys.forward":p,"keys.registerAction":e=>l({type:"keys.registerAction.result",id:e.id,actionId:`action-${String(e.id)}`}),"keys.unregisterAction":p,"media.session.create":e=>l({type:"media.session.create.result",id:e.id,sessionId:`session-${String(e.id)}`,owner:e.owner}),"media.session.update":p,"media.session.destroy":p,"media.state":p,"media.capabilities":p,"notify.send":e=>l({type:"notify.send.result",id:e.id,notificationId:`notif-${String(e.id)}`}),"notify.dismiss":p,"notify.badge":p,"notify.channel.register":p,"notify.permission.request":e=>l({type:"notify.permission.result",id:e.id,granted:!0}),"config.registerSchema":e=>l({type:"config.registerSchema.result",id:e.id,ok:!0}),"config.get":e=>l({type:"config.values",id:e.id,values:{}}),"config.subscribe":()=>l({type:"config.values",values:{}}),"config.unsubscribe":p,"config.openSettings":p,"resource.bytes":e=>l({type:"resource.bytes.result",id:e.id,blob:new Blob([]),mime:"application/octet-stream"}),"resource.cancel":p,"cvm.discover":e=>l({type:"cvm.discover.result",id:e.id,servers:[]}),"cvm.request":e=>l({type:"cvm.request.result",id:e.id,message:{}}),"cvm.close":e=>l({type:"cvm.close.result",id:e.id}),"outbox.query":e=>l({type:"outbox.query.result",id:e.id,events:[],relays:{}}),"outbox.subscribe":e=>l({type:"outbox.eose",subId:e.subId}),"outbox.close":p,"outbox.publish":e=>l({type:"outbox.publish.result",id:e.id,ok:!0}),"outbox.resolveRelays":e=>l({type:"outbox.resolveRelays.result",id:e.id,plan:{}}),"upload.upload":e=>l({type:"upload.upload.result",id:e.id,result:{url:J}}),"upload.status":e=>l({type:"upload.status.result",id:e.id,status:{}}),"intent.invoke":e=>l({type:"intent.invoke.result",id:e.id,result:{}}),"intent.available":e=>l({type:"intent.available.result",id:e.id,availability:{}}),"intent.handlers":e=>l({type:"intent.handlers.result",id:e.id,handlers:[]})};function W(e={}){const t=e.now??(()=>Date.now()),s={domains:e.capabilities?.domains??[...I],protocols:e.capabilities?.protocols??{}},o=e.services??[],i=e.class??null,r=[];function d(c){const n=c&&typeof c=="object"&&typeof c.type=="string"?c.type:void 0;if(n==="shell.ready")return[{type:"shell.init",capabilities:{domains:s.domains,protocols:s.protocols},services:o,class:i}];if(r.push({envelope:c,verdict:U(c),timestamp:t()}),!n)return[];const f=V[n];return f?f(c):[]}return{capabilities:s,services:o,class:i,get records(){return r},handle:d,reset(){r.length=0}}}function Y(e){return{manifestHtml:e.manifestHtml,sandbox:e.sandbox??{allowScripts:!0,allowSameOrigin:!1},installedGlobal:e.boot.installedGlobal,bootError:e.boot.bootError,emitted:e.boot.emitted,forbiddenGlobals:e.forbiddenGlobals??[],degraded:e.boot.degraded,lifecycle:e.lifecycle??null}}function j(e){return new Promise(t=>setTimeout(t,e))}async function x(e,t,s,o){const i=W(e?{capabilities:e}:void 0),r=s.createElement("iframe");r.setAttribute("sandbox",t.allowSameOrigin?"allow-scripts allow-same-origin":"allow-scripts"),r.setAttribute("aria-hidden","true"),r.style.position="absolute",r.style.width="1px",r.style.height="1px",r.style.left="-9999px";let d;const c=new Promise(b=>{d=b}),n=b=>{if(b.source!==r.contentWindow)return;const H=i.handle(b.data),D=b.source;for(const F of H)D?.postMessage(F,"*");const $=b.data;$&&typeof $=="object"&&$.type==="shell.ready"&&d()};o.addEventListener("message",n),(s.body??s.documentElement).appendChild(r),r.setAttribute("src",t.url);let f=!1,v=null;const O=Symbol("timeout");await Promise.race([c.then(()=>"ready"),j(t.readyTimeoutMs).then(()=>O)])===O?v=`napplet did not boot (no shell.ready within ${t.readyTimeoutMs}ms; a conformant napplet must run under sandbox="allow-scripts" without allow-same-origin)`:(f=!0,await j(t.settleMs)),o.removeEventListener("message",n);try{r.remove()}catch{}return{installedGlobal:f,bootError:v,emitted:[...i.records]}}async function X(e){const t=e.document??document,s=e.window??window,o={url:e.url,readyTimeoutMs:e.readyTimeoutMs??5e3,settleMs:e.settleMs,allowSameOrigin:e.allowSameOrigin??!1},i=await x(void 0,o,t,s);let r=null;{const d=await x({domains:[],protocols:{}},o,t,s);r={bootError:d.bootError,emitted:d.emitted}}return{installedGlobal:i.installedGlobal,bootError:i.bootError,emitted:i.emitted,degraded:r}}var Q=e=>({status:"pass",detail:e}),ee=(e,t)=>({status:"fail",detail:e,diagnostics:t}),te=e=>({status:"skip",detail:e}),u={pass:Q,fail:ee,skip:te};function ie(e,t){return k(e).errors.filter(s=>t.includes(s.code))}function M(e,t){return new RegExp(`name\\s*=\\s*["']${t}["']`,"i").test(e)}function A(e,t,s,o){return{id:e,area:"manifest",severity:"error",title:t,run:i=>{if(o&&!M(i.manifestHtml,o))return u.skip(`No ${o} declared`);const r=ie(i.manifestHtml,s);return r.length?u.fail(r[0].message,r):u.pass()}}}var se=[A("manifest/napplet-type","Declares a valid napplet-type",["missing-napplet-type","invalid-napplet-type"]),A("manifest/declared-naps","napplet-requires lists only real NAP domains",["unknown-required-nap"],"napplet-requires"),A("manifest/config-schema","Config schema is a draft-07 core subset",["invalid-config-schema"],"napplet-config-schema"),{id:"boot/sandbox-allow-scripts",area:"boot",severity:"error",title:"Runs under allow-scripts without allow-same-origin",run:e=>e.sandbox.allowScripts?e.sandbox.allowSameOrigin?u.fail("Napplet relied on allow-same-origin, which a shell must not grant"):u.pass():u.fail("Napplet was not loaded with allow-scripts")},{id:"boot/installs-global",area:"boot",severity:"error",title:"Completes the NAP-SHELL handshake (posts shell.ready)",run:e=>e.installedGlobal?u.pass():u.fail("window.napplet was not present after load (no NAP-SHELL shell.ready)")},{id:"boot/no-boot-error",area:"boot",severity:"error",title:"Boots without an uncaught error (observed via NAP-SHELL shell.ready)",run:e=>e.bootError?u.fail(e.bootError):u.pass()},{id:"boot/no-forbidden-globals",area:"boot",severity:"error",title:"Does not access forbidden globals (e.g. window.nostr)",run:e=>e.forbiddenGlobals.length?u.fail(`Accessed forbidden global(s): ${e.forbiddenGlobals.join(", ")}`,e.forbiddenGlobals):u.pass()},{id:"wire/envelope-well-formed",area:"wire",severity:"error",title:"Every emitted envelope is well-formed",run:e=>{if(e.emitted.length===0)return u.skip("Napplet emitted no envelopes");const t=e.emitted.filter(o=>!o.verdict.ok);if(t.length===0)return u.pass(`${e.emitted.length} envelope(s) valid`);const s=t.map(o=>({envelope:o.envelope,errors:o.verdict.errors}));return u.fail(`${t.length}/${e.emitted.length} emitted envelope(s) invalid`,s)}},{id:"wire/declared-naps-only",area:"wire",severity:"warning",title:"Only emits NAP domains it declared in napplet-requires",run:e=>{if(e.emitted.length===0)return u.skip("Napplet emitted no envelopes");if(!M(e.manifestHtml,"napplet-requires"))return u.skip("No napplet-requires to compare against");const t=new Set(k(e.manifestHtml).requires.map(i=>i.startsWith("nap:")?i.slice(4):i)),o=[...new Set(e.emitted.map(i=>i.verdict.domain).filter(i=>typeof i=="string"))].filter(i=>!t.has(i));return o.length?u.fail(`Emitted undeclared NAP domain(s): ${o.join(", ")}`,o):u.pass()}},{id:"degrade/supports-false",area:"degradation",severity:"error",title:"Does not crash when the NAP-SHELL environment offers no capabilities (supports() false)",run:e=>e.degraded?e.degraded.bootError?u.fail(`Crashed under a no-capability shell: ${e.degraded.bootError}`):u.pass():u.skip("Host did not run the degraded (no-capabilities) pass")},{id:"lifecycle/clean-teardown",area:"lifecycle",severity:"warning",title:"Tears down listeners on unload",run:e=>e.lifecycle?e.lifecycle.listenerLeak?u.fail("Message listeners remained after unload"):u.pass():u.skip("Lifecycle not measured")}];function re(e,t={}){const s=t.now??(()=>Date.now()),o=t.checks??se,i=s(),r=o.map(n=>{const f=n.run(e);return{id:n.id,area:n.area,severity:n.severity,title:n.title,status:f.status,detail:f.detail,diagnostics:f.diagnostics}}),d={total:r.length,passed:r.filter(n=>n.status==="pass").length,failed:r.filter(n=>n.status==="fail").length,skipped:r.filter(n=>n.status==="skip").length,errors:r.filter(n=>n.status==="fail"&&n.severity==="error").length,warnings:r.filter(n=>n.status==="fail"&&n.severity==="warning").length};return{napplet:k(e.manifestHtml).nappletType,startedAt:i,finishedAt:s(),checks:r,summary:d,ok:d.errors===0}}const ne=/\bwindow\s*\.\s*nostr\b|\bglobalThis\s*\.\s*nostr\b/,oe="/__conformance__/events",C=new URLSearchParams(location.search),T=C.get("live")==="1";function y(e){return String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}const g=document.getElementById("app");function ae(){return g.innerHTML=`
|
|
2
|
+
<header class="masthead">
|
|
3
|
+
<h1>napplet conformance ${T?'<span class="live-badge" id="live-badge">● live</span>':""}</h1>
|
|
4
|
+
<p>Load a napplet by URL and run NAP protocol conformance live. Same engine as <code>napplet-conformance</code> CI.</p>
|
|
5
|
+
</header>
|
|
6
|
+
<form id="run-form" class="runbar">
|
|
7
|
+
<input id="url" type="url" placeholder="https://localhost:5173/ (a napplet served with permissive CORS)" autocomplete="off" />
|
|
8
|
+
<button id="run" type="submit">Run conformance</button>
|
|
9
|
+
<button id="rerun" type="button" class="secondary" hidden>Re-run</button>
|
|
10
|
+
</form>
|
|
11
|
+
<p id="status" class="status" role="status"></p>
|
|
12
|
+
<section id="output" class="output"></section>
|
|
13
|
+
`,{urlInput:g.querySelector("#url"),runButton:g.querySelector("#run"),rerunButton:g.querySelector("#rerun"),liveBadge:g.querySelector("#live-badge")??document.createElement("span"),status:g.querySelector("#status"),output:g.querySelector("#output")}}const m=ae();let h="",P=0;function w(e,t="info"){m.status.textContent=e,m.status.dataset.kind=t}function N(e){return String(e).padStart(2,"0")}function le(e){const t=new Date(e);return`${N(t.getHours())}:${N(t.getMinutes())}:${N(t.getSeconds())}`}async function de(e){try{const s=await(await fetch(e,{mode:"cors"})).text();return{html:s,forbidden:ne.test(s)?["window.nostr"]:[],fetched:!0}}catch{return{html:"",forbidden:[],fetched:!1}}}function ue(e){const t=e.ok?"conformant":"non-conformant",o=[...new Set(e.checks.map(d=>d.area))].map(d=>{const c=e.checks.filter(n=>n.area===d).map(n=>{const f=n.severity==="warning"&&n.status==="fail"?' <span class="tag warn">warning</span>':"",v=n.detail?`<span class="detail">${y(n.detail)}</span>`:"";return`<li class="check ${n.status}"><span class="badge ${n.status}">${n.status}</span><span class="id">${y(n.id)}</span>${f}${v}</li>`}).join("");return`<div class="area"><h3>${y(d)}</h3><ul>${c}</ul></div>`}).join(""),i=e.summary,r=P>1?`<span class="run-stamp">run #${P} · ${le(e.finishedAt)}</span>`:"";return`
|
|
14
|
+
<div class="verdict ${t}">
|
|
15
|
+
<span class="result">${e.ok?"CONFORMANT":"NON-CONFORMANT"}</span>
|
|
16
|
+
<span class="counts">${i.passed} passed · ${i.failed} failed · ${i.skipped} skipped${i.warnings?` · ${i.warnings} warning(s)`:""}</span>
|
|
17
|
+
${r}
|
|
18
|
+
</div>
|
|
19
|
+
<div class="checks">${o}</div>
|
|
20
|
+
`}function ce(e){return e.length===0?'<p class="empty">No envelopes were emitted.</p>':`<ul class="envlog">${e.map(s=>{const o=s.verdict.ok?"ok":"bad",i=s.verdict.ok?"":`<div class="errs">${s.verdict.errors.map(r=>y(`${r.code}: ${r.message}`)).join("<br>")}</div>`;return`<li class="env ${o}"><code>${y(JSON.stringify(s.envelope))}</code>${i}</li>`}).join("")}</ul>`}function pe(e,t){if(!t)return`<p class="empty">Couldn't fetch the napplet HTML (likely CORS). Serve it with <code>Access-Control-Allow-Origin: *</code>, or use the CLI for the authoritative manifest checks.</p>`;const s=k(e),o=(i,r)=>`<tr><th>${y(i)}</th><td>${r?y(String(r)):"<em>—</em>"}</td></tr>`;return`
|
|
21
|
+
<table class="manifest">
|
|
22
|
+
${o("napplet-type",s.nappletType)}
|
|
23
|
+
${o("requires",s.requires.join(", "))}
|
|
24
|
+
</table>
|
|
25
|
+
${s.errors.length?`<div class="errs">${s.errors.map(i=>y(`${i.code}: ${i.message}`)).join("<br>")}</div>`:""}
|
|
26
|
+
`}function fe(e,t,s,o){m.output.innerHTML=`
|
|
27
|
+
${ue(e)}
|
|
28
|
+
<div class="panels">
|
|
29
|
+
<section class="panel"><h2>Emitted envelopes (${t.length})</h2>${ce(t)}</section>
|
|
30
|
+
<section class="panel"><h2>Manifest</h2>${pe(s,o)}</section>
|
|
31
|
+
</div>
|
|
32
|
+
`}async function E(e){h=e,m.runButton.disabled=!0,m.rerunButton.disabled=!0;try{w(`Fetching manifest for ${e} …`);const{html:t,forbidden:s,fetched:o}=await de(e);w("Booting napplet in a sandboxed iframe …");const i=await X({url:e,readyTimeoutMs:5e3,settleMs:800,runDegraded:!0}),r=Y({manifestHtml:t,boot:i,forbiddenGlobals:s}),d=re(r);P+=1,w(d.ok?"Done — conformant.":"Done — non-conformant.",d.ok?"info":"error"),fe(d,i.emitted,t,o),m.rerunButton.hidden=!1}catch(t){w(`Error: ${t instanceof Error?t.message:String(t)}`,"error")}finally{m.runButton.disabled=!1,m.rerunButton.disabled=!1}}function me(){if(!T||typeof EventSource>"u")return;let e=!1;const t=new EventSource(oe);t.addEventListener("rerun",()=>{!h||e||(e=!0,setTimeout(()=>{e=!1,E(h)},50))}),t.addEventListener("open",()=>{m.liveBadge&&(m.liveBadge.dataset.state="connected")}),t.addEventListener("error",()=>{m.liveBadge&&(m.liveBadge.dataset.state="disconnected")})}m.runButton.parentElement.addEventListener("submit",e=>{e.preventDefault();const t=m.urlInput.value.trim();if(t){const s=new URL(location.href);s.searchParams.set("url",t),history.replaceState(null,"",s),E(t)}});m.rerunButton.addEventListener("click",()=>{h&&E(h)});me();const q=C.get("url");q&&(m.urlInput.value=q,E(q));
|
package/dist/ui/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>napplet conformance runtime</title>
|
|
7
7
|
<meta name="description" content="Load a napplet and run NAP protocol conformance checks live in your browser." />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-Cn9jwNt-.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-D4WUxbBl.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@napplet/conformance-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Headless CLI that runs @napplet/conformance against a napplet in real Chromium via Playwright — the `test:conformance` runner",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"playwright": "^1.59.1",
|
|
18
|
-
"@napplet/conformance": "^0.
|
|
18
|
+
"@napplet/conformance": "^0.2.1"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@types/node": "^22.15.0",
|
|
22
22
|
"tsup": "^8.5.0",
|
|
23
23
|
"typescript": "^5.9.3",
|
|
24
|
-
"@napplet/conformance-web": "0.0.
|
|
24
|
+
"@napplet/conformance-web": "0.0.2"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"repository": {
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const r of s)if(r.type==="childList")for(const d of r.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&n(d)}).observe(document,{childList:!0,subtree:!0});function i(s){const r={};return s.integrity&&(r.integrity=s.integrity),s.referrerPolicy&&(r.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?r.credentials="include":s.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(s){if(s.ep)return;s.ep=!0;const r=i(s);fetch(s.href,r)}})();var T=["relay","identity","storage","inc","theme","keys","media","notify","config","resource","connect","class","cvm","outbox","upload","intent"],f="[@napplet/nap/connect]",K=new Set(["https","wss","http","ws"]),z={https:"443",wss:"443",http:"80",ws:"80"};function W(e){const t=e.indexOf("://");if(t<0)throw new Error(`${f} origin missing scheme separator '://': ${e}`);const i=e.slice(0,t);if(i!==i.toLowerCase())throw new Error(`${f} scheme must be lowercase: ${e}`);if(!K.has(i))throw new Error(`${f} scheme must be one of https|wss|http|ws: ${e}`);return{scheme:i,rest:e.slice(t+3)}}function V(e,t){if(e.includes("/"))throw new Error(`${f} origin MUST NOT contain a path: ${t}`);if(e.includes("?"))throw new Error(`${f} origin MUST NOT contain a query: ${t}`);if(e.includes("#"))throw new Error(`${f} origin MUST NOT contain a fragment: ${t}`);if(e.includes("*"))throw new Error(`${f} wildcard origins are not permitted: ${t}`);if(e.startsWith("["))throw new Error(`${f} IPv6 literal origins are out of v1 scope: ${t}`)}function Z(e,t,i){let n=t;const s=t.lastIndexOf(":");if(s>=0){n=t.slice(0,s);const r=t.slice(s+1);if(!/^[0-9]+$/.test(r))throw new Error(`${f} port must be decimal digits: ${i}`);const d=Number(r);if(d<1||d>65535)throw new Error(`${f} port out of range 1-65535: ${i}`);if(z[e]===r)throw new Error(`${f} default port ${r} MUST be omitted for scheme ${e}: ${i}`)}return J(n,i),n}function J(e,t){if(e.includes(":"))throw new Error(`${f} IPv6 literal origins are out of v1 scope: ${t}`);if(e.length===0)throw new Error(`${f} host is empty: ${t}`);if(e!==e.toLowerCase())throw new Error(`${f} host MUST be lowercase: ${t}`);if(!/^[\x00-\x7F]+$/.test(e))throw new Error(`${f} non-ASCII host MUST be Punycode-encoded (xn--...) before calling: ${t}`)}function X(e,t){if(!/^[0-9]+(\.[0-9]+){3}$/.test(e))return!1;for(const i of e.split(".")){if(i.length>1&&i.startsWith("0"))throw new Error(`${f} IPv4 octet MUST NOT have leading zeros: ${t}`);const n=Number(i);if(!Number.isInteger(n)||n<0||n>255)throw new Error(`${f} IPv4 octet out of range 0-255: ${t}`)}return!0}function Y(e,t){for(const i of e.split(".")){if(i.length===0||i.length>63)throw new Error(`${f} invalid host label length: ${t}`);if(!/^[a-z0-9-]+$/.test(i))throw new Error(`${f} host label contains invalid characters: ${t}`);if(i.startsWith("-")||i.endsWith("-"))throw new Error(`${f} host label MUST NOT start or end with hyphen: ${t}`)}}function Q(e){if(typeof e!="string"||e.length===0)throw new Error(`${f} origin must be a non-empty string`);const{scheme:t,rest:i}=W(e);V(i,e);const n=Z(t,i,e);return X(n,e)||Y(n,e),e}var o={id:"string"},ee={"relay.subscribe":{dir:"out",fields:{...o,subId:"string",filters:"array"}},"relay.close":{dir:"out",fields:{...o,subId:"string"}},"relay.publish":{dir:"out",fields:{...o,event:"object"}},"relay.publishEncrypted":{dir:"out",fields:{...o,event:"object",recipient:"string"}},"relay.query":{dir:"out",fields:{...o,filters:"array"}},"relay.event":{dir:"in"},"relay.eose":{dir:"in"},"relay.closed":{dir:"in"},"relay.publish.result":{dir:"in"},"relay.publishEncrypted.result":{dir:"in"},"relay.query.result":{dir:"in"},"identity.getPublicKey":{dir:"out",fields:{...o}},"identity.getRelays":{dir:"out",fields:{...o}},"identity.getProfile":{dir:"out",fields:{...o}},"identity.getFollows":{dir:"out",fields:{...o}},"identity.getList":{dir:"out",fields:{...o,listType:"string"}},"identity.getZaps":{dir:"out",fields:{...o}},"identity.getMutes":{dir:"out",fields:{...o}},"identity.getBlocked":{dir:"out",fields:{...o}},"identity.getBadges":{dir:"out",fields:{...o}},"identity.changed":{dir:"in"},"identity.getPublicKey.result":{dir:"in"},"identity.getRelays.result":{dir:"in"},"identity.getProfile.result":{dir:"in"},"identity.getFollows.result":{dir:"in"},"identity.getList.result":{dir:"in"},"identity.getZaps.result":{dir:"in"},"identity.getMutes.result":{dir:"in"},"identity.getBlocked.result":{dir:"in"},"identity.getBadges.result":{dir:"in"},"storage.get":{dir:"out",fields:{...o,key:"string"}},"storage.set":{dir:"out",fields:{...o,key:"string",value:"string"}},"storage.remove":{dir:"out",fields:{...o,key:"string"}},"storage.keys":{dir:"out",fields:{...o}},"storage.get.result":{dir:"in"},"storage.set.result":{dir:"in"},"storage.remove.result":{dir:"in"},"storage.keys.result":{dir:"in"},"inc.emit":{dir:"out",fields:{topic:"string"}},"inc.subscribe":{dir:"out",fields:{...o,topic:"string"}},"inc.unsubscribe":{dir:"out",fields:{topic:"string"}},"inc.channel.open":{dir:"out",fields:{...o,target:"string"}},"inc.channel.emit":{dir:"out",fields:{channelId:"string"}},"inc.channel.broadcast":{dir:"out"},"inc.channel.list":{dir:"out",fields:{...o}},"inc.channel.close":{dir:"out",fields:{channelId:"string"}},"inc.subscribe.result":{dir:"in"},"inc.event":{dir:"in"},"inc.channel.open.result":{dir:"in"},"inc.channel.event":{dir:"in"},"inc.channel.list.result":{dir:"in"},"inc.channel.closed":{dir:"in"},"theme.get":{dir:"out",fields:{...o}},"theme.get.result":{dir:"in"},"theme.changed":{dir:"in"},"keys.forward":{dir:"out",fields:{key:"string",code:"string",ctrl:"boolean",alt:"boolean",shift:"boolean",meta:"boolean"}},"keys.registerAction":{dir:"out",fields:{...o,action:"object"}},"keys.unregisterAction":{dir:"out",fields:{actionId:"string"}},"keys.registerAction.result":{dir:"in"},"keys.bindings":{dir:"in"},"keys.action":{dir:"in"},"media.session.create":{dir:"out",fields:{...o,owner:"object"}},"media.session.update":{dir:"out",fields:{sessionId:"string",metadata:"object"}},"media.session.destroy":{dir:"out",fields:{sessionId:"string"}},"media.state":{dir:"out",fields:{sessionId:"string",status:"string"}},"media.capabilities":{dir:"out",fields:{sessionId:"string",actions:"array"}},"media.session.create.result":{dir:"in"},"media.command":{dir:"in"},"media.controls":{dir:"in"},"notify.send":{dir:"out",fields:{...o,title:"string"}},"notify.dismiss":{dir:"out",fields:{notificationId:"string"}},"notify.badge":{dir:"out",fields:{count:"number"}},"notify.channel.register":{dir:"out",fields:{channelId:"string",label:"string"}},"notify.permission.request":{dir:"out",fields:{...o}},"notify.send.result":{dir:"in"},"notify.permission.result":{dir:"in"},"notify.action":{dir:"in"},"notify.clicked":{dir:"in"},"notify.dismissed":{dir:"in"},"notify.controls":{dir:"in"},"config.registerSchema":{dir:"out",fields:{...o,schema:"object"}},"config.get":{dir:"out",fields:{...o}},"config.subscribe":{dir:"out"},"config.unsubscribe":{dir:"out"},"config.openSettings":{dir:"out"},"config.registerSchema.result":{dir:"in"},"config.values":{dir:"in"},"config.schemaError":{dir:"in"},"resource.bytes":{dir:"out",fields:{...o,url:"string"}},"resource.cancel":{dir:"out",fields:{...o}},"resource.bytes.result":{dir:"in"},"resource.bytes.error":{dir:"in"},"class.assigned":{dir:"in"},"cvm.discover":{dir:"out",fields:{...o}},"cvm.request":{dir:"out",fields:{...o,server:"object",message:"object"}},"cvm.close":{dir:"out",fields:{...o,server:"object"}},"cvm.discover.result":{dir:"in"},"cvm.request.result":{dir:"in"},"cvm.close.result":{dir:"in"},"cvm.event":{dir:"in"},"outbox.query":{dir:"out",fields:{...o,filters:"present"}},"outbox.subscribe":{dir:"out",fields:{...o,subId:"string",filters:"present"}},"outbox.close":{dir:"out",fields:{...o,subId:"string"}},"outbox.publish":{dir:"out",fields:{...o,event:"object"}},"outbox.resolveRelays":{dir:"out",fields:{...o,target:"present"}},"outbox.query.result":{dir:"in"},"outbox.event":{dir:"in"},"outbox.eose":{dir:"in"},"outbox.closed":{dir:"in"},"outbox.publish.result":{dir:"in"},"outbox.resolveRelays.result":{dir:"in"},"upload.upload":{dir:"out",fields:{...o,request:"object"}},"upload.status":{dir:"out",fields:{...o,uploadId:"string"}},"upload.upload.result":{dir:"in"},"upload.status.result":{dir:"in"},"upload.status.changed":{dir:"in"},"intent.invoke":{dir:"out",fields:{...o,request:"object"}},"intent.available":{dir:"out",fields:{...o,archetype:"string"}},"intent.handlers":{dir:"out",fields:{...o}},"intent.invoke.result":{dir:"in"},"intent.available.result":{dir:"in"},"intent.handlers.result":{dir:"in"},"intent.changed":{dir:"in"}};function j(e){if(e===void 0)return"undefined";if(e===null)return"null";if(Array.isArray(e))return"array";const t=typeof e;return t==="string"||t==="number"||t==="boolean"?t:t==="object"?"object":"present"}function te(e,t){return t==="present"?e!=null:j(e)===t}function ie(e){const t=[];if(typeof e!="object"||e===null||Array.isArray(e))return{ok:!1,errors:[{code:"not-an-object",message:"Envelope must be a non-null object"}]};const i=e,n=i.type;if(typeof n!="string")return{ok:!1,errors:[{code:"missing-type",message:"Envelope is missing a string `type` field"}]};const s=n.indexOf(".");if(s<=0)return{ok:!1,type:n,errors:[{code:"malformed-type",message:`Envelope type "${n}" is not in domain.action form`}]};const r=n.slice(0,s);if(!T.includes(r))return{ok:!1,type:n,domain:r,errors:[{code:"unknown-domain",message:`"${r}" is not a known NAP domain`}]};const d=ee[n];if(!d)return{ok:!1,type:n,domain:r,errors:[{code:"unknown-type",message:`"${n}" is not a known ${r} message type`}]};if(d.dir==="in")return{ok:!1,type:n,domain:r,direction:"in",errors:[{code:"inbound-type-emitted",message:`"${n}" is a shell→napplet message; a napplet must not emit it`}]};for(const[u,l]of Object.entries(d.fields??{})){if(!(u in i)||i[u]===void 0){t.push({code:"missing-field",message:`Required field "${u}" is missing`,field:u});continue}te(i[u],l)||t.push({code:"wrong-type",message:`Field "${u}" should be ${l} but is ${j(i[u])}`,field:u})}return{ok:t.length===0,type:n,domain:r,direction:"out",errors:t}}var M=/^[a-z0-9][a-z0-9._:-]*$/,se=new Set(["application/json","application/ld+json","importmap","speculationrules"]);function re(e){return e.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g,(t,i)=>{if(i[0]==="#"){const s=i[1]==="x"||i[1]==="X"?parseInt(i.slice(2),16):parseInt(i.slice(1),10);return Number.isFinite(s)?String.fromCodePoint(s):t}return{amp:"&",lt:"<",gt:">",quot:'"',apos:"'"}[i]??t})}function k(e,t){const i=/<meta\b[^>]*>/gi;let n;for(;(n=i.exec(e))!==null;){const s=n[0],r=/\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(s);if((r?.[1]??r?.[2]??"").trim()!==t)continue;const u=/\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(s);return re((u?.[1]??u?.[2]??"").trim())}}function ne(e){const t=e.replace(/<!--[\s\S]*?-->/g,""),i=/<script\b([\s\S]*?)>/gi,n=[];let s;for(;(s=i.exec(t))!==null;){const r=s[0],d=s[1]??"",u=/\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(d);if(u){if((u[1]??u[2]??"").trim().length>0)continue;n.push(r);continue}const l=/\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(d),y=(l?.[1]??l?.[2]??"").trim().toLowerCase();y&&se.has(y)||n.push(r)}return n}function R(e){if(Array.isArray(e))return e.some(R);if(e&&typeof e=="object"){const t=e;return"pattern"in t?!0:Object.values(t).some(R)}return!1}function N(e,t={}){const i=[],n=[],s=k(e,"napplet-type");s?M.test(s)||i.push({code:"invalid-napplet-type",message:`napplet-type "${s}" must match ${M}`}):i.push({code:"missing-napplet-type",message:'No <meta name="napplet-type"> declared'});const r=k(e,"napplet-requires"),d=r?r.split(",").map(p=>p.trim()).filter(Boolean):[];for(const p of d){const $=p.startsWith("nap:")?p.slice(4):p;T.includes($)||i.push({code:"unknown-required-nap",message:`napplet-requires lists "${p}" which is not a known NAP domain`})}const u=k(e,"napplet-config-schema");if(u){let p;try{p=JSON.parse(u)}catch{p=void 0,i.push({code:"invalid-config-schema",message:"napplet-config-schema is not valid JSON"})}p!==void 0&&(typeof p!="object"||p===null||Array.isArray(p)?i.push({code:"invalid-config-schema",message:"napplet-config-schema must be a JSON Schema object"}):R(p)&&i.push({code:"invalid-config-schema",message:"napplet-config-schema uses the `pattern` keyword, which the draft-07 core subset excludes (CVE-2025-69873)"}))}const l=k(e,"napplet-connect-requires"),y=l?l.split(/\s+/).map(p=>p.trim()).filter(Boolean):[],b=[];for(const p of y)try{b.push(Q(p))}catch($){const h=$ instanceof Error?$.message:String($);i.push({code:"invalid-connect-origin",message:`connect origin "${p}" is invalid: ${h}`})}for(const p of ne(e))i.push({code:"inline-script",message:`Inline <script> is forbidden under script-src 'self': ${p}`});return{ok:i.length===0,nappletType:s,requires:d,connectOrigins:b,errors:i,warnings:n}}var oe="f".repeat(64),ae="https://reference.invalid/blob",a=e=>[e],m=()=>[],le={"relay.subscribe":e=>a({type:"relay.eose",subId:e.subId}),"relay.close":m,"relay.publish":e=>a({type:"relay.publish.result",id:e.id,ok:!0,event:e.event}),"relay.publishEncrypted":e=>a({type:"relay.publishEncrypted.result",id:e.id,ok:!0,event:e.event}),"relay.query":e=>a({type:"relay.query.result",id:e.id,events:[]}),"identity.getPublicKey":e=>a({type:"identity.getPublicKey.result",id:e.id,pubkey:oe}),"identity.getRelays":e=>a({type:"identity.getRelays.result",id:e.id,relays:{}}),"identity.getProfile":e=>a({type:"identity.getProfile.result",id:e.id,profile:null}),"identity.getFollows":e=>a({type:"identity.getFollows.result",id:e.id,pubkeys:[]}),"identity.getList":e=>a({type:"identity.getList.result",id:e.id,entries:[]}),"identity.getZaps":e=>a({type:"identity.getZaps.result",id:e.id,zaps:[]}),"identity.getMutes":e=>a({type:"identity.getMutes.result",id:e.id,pubkeys:[]}),"identity.getBlocked":e=>a({type:"identity.getBlocked.result",id:e.id,pubkeys:[]}),"identity.getBadges":e=>a({type:"identity.getBadges.result",id:e.id,badges:[]}),"storage.get":e=>a({type:"storage.get.result",id:e.id,value:null}),"storage.set":e=>a({type:"storage.set.result",id:e.id}),"storage.remove":e=>a({type:"storage.remove.result",id:e.id}),"storage.keys":e=>a({type:"storage.keys.result",id:e.id,keys:[]}),"inc.emit":m,"inc.subscribe":e=>a({type:"inc.subscribe.result",id:e.id}),"inc.unsubscribe":m,"inc.channel.open":e=>a({type:"inc.channel.open.result",id:e.id,channelId:`chan-${String(e.id)}`,peer:"reference-peer"}),"inc.channel.emit":m,"inc.channel.broadcast":m,"inc.channel.list":e=>a({type:"inc.channel.list.result",id:e.id,channels:[]}),"inc.channel.close":m,"theme.get":e=>a({type:"theme.get.result",id:e.id,theme:{colors:{},mode:"dark"}}),"keys.forward":m,"keys.registerAction":e=>a({type:"keys.registerAction.result",id:e.id,actionId:`action-${String(e.id)}`}),"keys.unregisterAction":m,"media.session.create":e=>a({type:"media.session.create.result",id:e.id,sessionId:`session-${String(e.id)}`,owner:e.owner}),"media.session.update":m,"media.session.destroy":m,"media.state":m,"media.capabilities":m,"notify.send":e=>a({type:"notify.send.result",id:e.id,notificationId:`notif-${String(e.id)}`}),"notify.dismiss":m,"notify.badge":m,"notify.channel.register":m,"notify.permission.request":e=>a({type:"notify.permission.result",id:e.id,granted:!0}),"config.registerSchema":e=>a({type:"config.registerSchema.result",id:e.id,ok:!0}),"config.get":e=>a({type:"config.values",id:e.id,values:{}}),"config.subscribe":()=>a({type:"config.values",values:{}}),"config.unsubscribe":m,"config.openSettings":m,"resource.bytes":e=>a({type:"resource.bytes.result",id:e.id,blob:new Blob([]),mime:"application/octet-stream"}),"resource.cancel":m,"cvm.discover":e=>a({type:"cvm.discover.result",id:e.id,servers:[]}),"cvm.request":e=>a({type:"cvm.request.result",id:e.id,message:{}}),"cvm.close":e=>a({type:"cvm.close.result",id:e.id}),"outbox.query":e=>a({type:"outbox.query.result",id:e.id,events:[],relays:{}}),"outbox.subscribe":e=>a({type:"outbox.eose",subId:e.subId}),"outbox.close":m,"outbox.publish":e=>a({type:"outbox.publish.result",id:e.id,ok:!0}),"outbox.resolveRelays":e=>a({type:"outbox.resolveRelays.result",id:e.id,plan:{}}),"upload.upload":e=>a({type:"upload.upload.result",id:e.id,result:{url:ae}}),"upload.status":e=>a({type:"upload.status.result",id:e.id,status:{}}),"intent.invoke":e=>a({type:"intent.invoke.result",id:e.id,result:{}}),"intent.available":e=>a({type:"intent.available.result",id:e.id,availability:{}}),"intent.handlers":e=>a({type:"intent.handlers.result",id:e.id,handlers:[]})};function de(e={}){const t=e.now??(()=>Date.now()),i={naps:e.capabilities?.naps??[...T],sandbox:e.capabilities?.sandbox??[]},n=[];function s(r){const d=r&&typeof r=="object"&&typeof r.type=="string"?r.type:void 0;if(d==="shell.ready")return[{type:"shell.init",capabilities:{naps:i.naps,sandbox:i.sandbox}}];if(n.push({envelope:r,verdict:ie(r),timestamp:t()}),!d)return[];const u=le[d];return u?u(r):[]}return{capabilities:i,get records(){return n},handle:s,reset(){n.length=0}}}function ce(e){return{manifestHtml:e.manifestHtml,sandbox:e.sandbox??{allowScripts:!0,allowSameOrigin:!1},installedGlobal:e.boot.installedGlobal,bootError:e.boot.bootError,emitted:e.boot.emitted,forbiddenGlobals:e.forbiddenGlobals??[],degraded:e.boot.degraded,lifecycle:e.lifecycle??null}}function C(e){return new Promise(t=>setTimeout(t,e))}async function L(e,t,i,n){const s=de(e?{capabilities:e}:void 0),r=i.createElement("iframe");r.setAttribute("sandbox",t.allowSameOrigin?"allow-scripts allow-same-origin":"allow-scripts"),r.setAttribute("aria-hidden","true"),r.style.position="absolute",r.style.width="1px",r.style.height="1px",r.style.left="-9999px";let d;const u=new Promise(h=>{d=h}),l=h=>{if(h.source!==r.contentWindow)return;const F=s.handle(h.data),U=h.source;for(const G of F)U?.postMessage(G,"*");const x=h.data;x&&typeof x=="object"&&x.type==="shell.ready"&&d()};n.addEventListener("message",l),(i.body??i.documentElement).appendChild(r),r.setAttribute("src",t.url);let y=!1,b=null;const p=Symbol("timeout");await Promise.race([u.then(()=>"ready"),C(t.readyTimeoutMs).then(()=>p)])===p?b=`napplet did not boot (no shell.ready within ${t.readyTimeoutMs}ms; a conformant napplet must run under sandbox="allow-scripts" without allow-same-origin)`:(y=!0,await C(t.settleMs)),n.removeEventListener("message",l);try{r.remove()}catch{}return{installedGlobal:y,bootError:b,emitted:[...s.records]}}async function ue(e){const t=e.document??document,i=e.window??window,n={url:e.url,readyTimeoutMs:e.readyTimeoutMs??5e3,settleMs:e.settleMs,allowSameOrigin:e.allowSameOrigin??!1},s=await L(void 0,n,t,i);let r=null;{const d=await L({naps:[],sandbox:[]},n,t,i);r={bootError:d.bootError,emitted:d.emitted}}return{installedGlobal:s.installedGlobal,bootError:s.bootError,emitted:s.emitted,degraded:r}}var pe=e=>({status:"pass",detail:e}),fe=(e,t)=>({status:"fail",detail:e,diagnostics:t}),me=e=>({status:"skip",detail:e}),c={pass:pe,fail:fe,skip:me};function B(e,t){return N(e).errors.filter(i=>t.includes(i.code))}function H(e,t){return new RegExp(`name\\s*=\\s*["']${t}["']`,"i").test(e)}function S(e,t,i,n){return{id:e,area:"manifest",severity:"error",title:t,run:s=>{if(n&&!H(s.manifestHtml,n))return c.skip(`No ${n} declared`);const r=B(s.manifestHtml,i);return r.length?c.fail(r[0].message,r):c.pass()}}}var ge=[S("manifest/napplet-type","Declares a valid napplet-type",["missing-napplet-type","invalid-napplet-type"]),S("manifest/declared-naps","napplet-requires lists only real NAP domains",["unknown-required-nap"],"napplet-requires"),S("manifest/config-schema","Config schema is a draft-07 core subset",["invalid-config-schema"],"napplet-config-schema"),S("manifest/connect-origins","Declared connect origins are valid",["invalid-connect-origin"],"napplet-connect-requires"),{id:"manifest/no-inline-scripts",area:"manifest",severity:"error",title:"No inline <script> (incompatible with script-src 'self')",run:e=>{const t=B(e.manifestHtml,["inline-script"]);return t.length?c.fail(`${t.length} inline script(s) found`,t):c.pass()}},{id:"boot/sandbox-allow-scripts",area:"boot",severity:"error",title:"Runs under allow-scripts without allow-same-origin",run:e=>e.sandbox.allowScripts?e.sandbox.allowSameOrigin?c.fail("Napplet relied on allow-same-origin, which a shell must not grant"):c.pass():c.fail("Napplet was not loaded with allow-scripts")},{id:"boot/installs-global",area:"boot",severity:"error",title:"Installs window.napplet",run:e=>e.installedGlobal?c.pass():c.fail("window.napplet was not present after load")},{id:"boot/no-boot-error",area:"boot",severity:"error",title:"Boots without an uncaught error",run:e=>e.bootError?c.fail(e.bootError):c.pass()},{id:"boot/no-forbidden-globals",area:"boot",severity:"error",title:"Does not access forbidden globals (e.g. window.nostr)",run:e=>e.forbiddenGlobals.length?c.fail(`Accessed forbidden global(s): ${e.forbiddenGlobals.join(", ")}`,e.forbiddenGlobals):c.pass()},{id:"wire/envelope-well-formed",area:"wire",severity:"error",title:"Every emitted envelope is well-formed",run:e=>{if(e.emitted.length===0)return c.skip("Napplet emitted no envelopes");const t=e.emitted.filter(n=>!n.verdict.ok);if(t.length===0)return c.pass(`${e.emitted.length} envelope(s) valid`);const i=t.map(n=>({envelope:n.envelope,errors:n.verdict.errors}));return c.fail(`${t.length}/${e.emitted.length} emitted envelope(s) invalid`,i)}},{id:"wire/declared-naps-only",area:"wire",severity:"warning",title:"Only emits NAP domains it declared in napplet-requires",run:e=>{if(e.emitted.length===0)return c.skip("Napplet emitted no envelopes");if(!H(e.manifestHtml,"napplet-requires"))return c.skip("No napplet-requires to compare against");const t=new Set(N(e.manifestHtml).requires.map(s=>s.startsWith("nap:")?s.slice(4):s)),n=[...new Set(e.emitted.map(s=>s.verdict.domain).filter(s=>typeof s=="string"))].filter(s=>!t.has(s));return n.length?c.fail(`Emitted undeclared NAP domain(s): ${n.join(", ")}`,n):c.pass()}},{id:"degrade/supports-false",area:"degradation",severity:"error",title:"Does not crash when shell.supports() is false",run:e=>e.degraded?e.degraded.bootError?c.fail(`Crashed under a no-capability shell: ${e.degraded.bootError}`):c.pass():c.skip("Host did not run the degraded (no-capabilities) pass")},{id:"lifecycle/clean-teardown",area:"lifecycle",severity:"warning",title:"Tears down listeners on unload",run:e=>e.lifecycle?e.lifecycle.listenerLeak?c.fail("Message listeners remained after unload"):c.pass():c.skip("Lifecycle not measured")}];function ye(e,t={}){const i=t.now??(()=>Date.now()),n=t.checks??ge,s=i(),r=n.map(l=>{const y=l.run(e);return{id:l.id,area:l.area,severity:l.severity,title:l.title,status:y.status,detail:y.detail,diagnostics:y.diagnostics}}),d={total:r.length,passed:r.filter(l=>l.status==="pass").length,failed:r.filter(l=>l.status==="fail").length,skipped:r.filter(l=>l.status==="skip").length,errors:r.filter(l=>l.status==="fail"&&l.severity==="error").length,warnings:r.filter(l=>l.status==="fail"&&l.severity==="warning").length};return{napplet:N(e.manifestHtml).nappletType,startedAt:s,finishedAt:i(),checks:r,summary:d,ok:d.errors===0}}const be=/\bwindow\s*\.\s*nostr\b|\bglobalThis\s*\.\s*nostr\b/,he="/__conformance__/events",_=new URLSearchParams(location.search),D=_.get("live")==="1";function v(e){return String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}const w=document.getElementById("app");function ve(){return w.innerHTML=`
|
|
2
|
-
<header class="masthead">
|
|
3
|
-
<h1>napplet conformance ${D?'<span class="live-badge" id="live-badge">● live</span>':""}</h1>
|
|
4
|
-
<p>Load a napplet by URL and run NAP protocol conformance live. Same engine as <code>napplet-conformance</code> CI.</p>
|
|
5
|
-
</header>
|
|
6
|
-
<form id="run-form" class="runbar">
|
|
7
|
-
<input id="url" type="url" placeholder="https://localhost:5173/ (a napplet served with permissive CORS)" autocomplete="off" />
|
|
8
|
-
<button id="run" type="submit">Run conformance</button>
|
|
9
|
-
<button id="rerun" type="button" class="secondary" hidden>Re-run</button>
|
|
10
|
-
</form>
|
|
11
|
-
<p id="status" class="status" role="status"></p>
|
|
12
|
-
<section id="output" class="output"></section>
|
|
13
|
-
`,{urlInput:w.querySelector("#url"),runButton:w.querySelector("#run"),rerunButton:w.querySelector("#rerun"),liveBadge:w.querySelector("#live-badge")??document.createElement("span"),status:w.querySelector("#status"),output:w.querySelector("#output")}}const g=ve();let E="",A=0;function I(e,t="info"){g.status.textContent=e,g.status.dataset.kind=t}function P(e){return String(e).padStart(2,"0")}function we(e){const t=new Date(e);return`${P(t.getHours())}:${P(t.getMinutes())}:${P(t.getSeconds())}`}async function $e(e){try{const i=await(await fetch(e,{mode:"cors"})).text();return{html:i,forbidden:be.test(i)?["window.nostr"]:[],fetched:!0}}catch{return{html:"",forbidden:[],fetched:!1}}}function Ee(e){const t=e.ok?"conformant":"non-conformant",n=[...new Set(e.checks.map(d=>d.area))].map(d=>{const u=e.checks.filter(l=>l.area===d).map(l=>{const y=l.severity==="warning"&&l.status==="fail"?' <span class="tag warn">warning</span>':"",b=l.detail?`<span class="detail">${v(l.detail)}</span>`:"";return`<li class="check ${l.status}"><span class="badge ${l.status}">${l.status}</span><span class="id">${v(l.id)}</span>${y}${b}</li>`}).join("");return`<div class="area"><h3>${v(d)}</h3><ul>${u}</ul></div>`}).join(""),s=e.summary,r=A>1?`<span class="run-stamp">run #${A} · ${we(e.finishedAt)}</span>`:"";return`
|
|
14
|
-
<div class="verdict ${t}">
|
|
15
|
-
<span class="result">${e.ok?"CONFORMANT":"NON-CONFORMANT"}</span>
|
|
16
|
-
<span class="counts">${s.passed} passed · ${s.failed} failed · ${s.skipped} skipped${s.warnings?` · ${s.warnings} warning(s)`:""}</span>
|
|
17
|
-
${r}
|
|
18
|
-
</div>
|
|
19
|
-
<div class="checks">${n}</div>
|
|
20
|
-
`}function ke(e){return e.length===0?'<p class="empty">No envelopes were emitted.</p>':`<ul class="envlog">${e.map(i=>{const n=i.verdict.ok?"ok":"bad",s=i.verdict.ok?"":`<div class="errs">${i.verdict.errors.map(r=>v(`${r.code}: ${r.message}`)).join("<br>")}</div>`;return`<li class="env ${n}"><code>${v(JSON.stringify(i.envelope))}</code>${s}</li>`}).join("")}</ul>`}function Se(e,t){if(!t)return`<p class="empty">Couldn't fetch the napplet HTML (likely CORS). Serve it with <code>Access-Control-Allow-Origin: *</code>, or use the CLI for the authoritative manifest checks.</p>`;const i=N(e),n=(s,r)=>`<tr><th>${v(s)}</th><td>${r?v(String(r)):"<em>—</em>"}</td></tr>`;return`
|
|
21
|
-
<table class="manifest">
|
|
22
|
-
${n("napplet-type",i.nappletType)}
|
|
23
|
-
${n("requires",i.requires.join(", "))}
|
|
24
|
-
${n("connect origins",i.connectOrigins.join(", "))}
|
|
25
|
-
</table>
|
|
26
|
-
${i.errors.length?`<div class="errs">${i.errors.map(s=>v(`${s.code}: ${s.message}`)).join("<br>")}</div>`:""}
|
|
27
|
-
`}function Ie(e,t,i,n){g.output.innerHTML=`
|
|
28
|
-
${Ee(e)}
|
|
29
|
-
<div class="panels">
|
|
30
|
-
<section class="panel"><h2>Emitted envelopes (${t.length})</h2>${ke(t)}</section>
|
|
31
|
-
<section class="panel"><h2>Manifest</h2>${Se(i,n)}</section>
|
|
32
|
-
</div>
|
|
33
|
-
`}async function O(e){E=e,g.runButton.disabled=!0,g.rerunButton.disabled=!0;try{I(`Fetching manifest for ${e} …`);const{html:t,forbidden:i,fetched:n}=await $e(e);I("Booting napplet in a sandboxed iframe …");const s=await ue({url:e,readyTimeoutMs:5e3,settleMs:800,runDegraded:!0}),r=ce({manifestHtml:t,boot:s,forbiddenGlobals:i}),d=ye(r);A+=1,I(d.ok?"Done — conformant.":"Done — non-conformant.",d.ok?"info":"error"),Ie(d,s.emitted,t,n),g.rerunButton.hidden=!1}catch(t){I(`Error: ${t instanceof Error?t.message:String(t)}`,"error")}finally{g.runButton.disabled=!1,g.rerunButton.disabled=!1}}function Ne(){if(!D||typeof EventSource>"u")return;let e=!1;const t=new EventSource(he);t.addEventListener("rerun",()=>{!E||e||(e=!0,setTimeout(()=>{e=!1,O(E)},50))}),t.addEventListener("open",()=>{g.liveBadge&&(g.liveBadge.dataset.state="connected")}),t.addEventListener("error",()=>{g.liveBadge&&(g.liveBadge.dataset.state="disconnected")})}g.runButton.parentElement.addEventListener("submit",e=>{e.preventDefault();const t=g.urlInput.value.trim();if(t){const i=new URL(location.href);i.searchParams.set("url",t),history.replaceState(null,"",i),O(t)}});g.rerunButton.addEventListener("click",()=>{E&&O(E)});Ne();const q=_.get("url");q&&(g.urlInput.value=q,O(q));
|