forked from ChelseaKR/ctdl-validate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
496 lines (474 loc) · 22 KB
/
Copy pathindex.html
File metadata and controls
496 lines (474 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
Content-Security-Policy: the playground needs exactly two network
origins. cdn.jsdelivr.net serves the Pyodide runtime and its wasm; 'self'
serves the ctdl-validate wheel built by this repo's own Pages workflow.
There is deliberately no pypi.org or files.pythonhosted.org here: unlike
the tods-validate playground, this one installs no published package, so
what runs in the browser is always the code at this commit.
'wasm-unsafe-eval' is required for Pyodide's WebAssembly execution;
'unsafe-inline' for the inline <script> and <style> below.
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; worker-src blob:; child-src blob:;"
/>
<title>ctdl-validate playground</title>
<!-- Inline, because without it every visitor's browser requests
/favicon.ico, gets a 404, and logs a console error. A tool whose
argument is that it tells the truth about itself can afford not to
ship a broken request on every page load. data: is already allowed by
img-src in the policy above.
nosemgrep: html.security.audit.missing-integrity.missing-integrity
The rule asks for a Subresource Integrity hash on externally hosted
files. This one is not hosted anywhere: the whole icon is in the
attribute, so there is no fetch for an attacker to intercept and no
hash SRI would accept. Suppressed here rather than repo-wide, so the
rule still fires on the next tag that does load something. -->
<!-- nosemgrep: html.security.audit.missing-integrity.missing-integrity -->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%E2%9C%93%3C/text%3E%3C/svg%3E"
/>
<style>
:root {
color-scheme: light dark;
--background: #ffffff;
--foreground: #111827;
--muted: #4b5563;
--border: #9ca3af;
--surface: rgba(127, 127, 127, 0.08);
--error: #b42318;
--warning: #b54708;
--info: #175cd3;
--unverifiable: #5925dc;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #111827;
--foreground: #f9fafb;
--muted: #d1d5db;
--border: #6b7280;
--error: #fda29b;
--warning: #fec84b;
--info: #84caff;
--unverifiable: #d6bbfb;
}
}
body {
font: 16px/1.5 system-ui, sans-serif;
max-width: 60rem;
margin: 2rem auto;
padding: 0 1rem;
color: var(--foreground);
background: var(--background);
}
h1 { margin-bottom: 0.25rem; }
.lede { color: var(--muted); margin-top: 0; }
.panel {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
textarea {
width: 100%;
min-height: 14rem;
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
resize: vertical;
}
button {
font: inherit;
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid #888;
background: var(--surface);
color: inherit;
cursor: pointer;
}
button:disabled { opacity: 0.65; cursor: progress; }
.samples { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.samples button { font-size: 0.9rem; padding: 0.35rem 0.7rem; }
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-top: 0.75rem; }
:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }
#status { color: var(--muted); margin: 0.75rem 0 0; }
.finding {
border: 1px solid var(--border);
border-left-width: 5px;
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
}
.finding.ERROR { border-left-color: var(--error); }
.finding.WARNING { border-left-color: var(--warning); }
.finding.INFO { border-left-color: var(--info); }
.finding.UNVERIFIABLE { border-left-color: var(--unverifiable); }
.sev { font-weight: 700; letter-spacing: 0.02em; }
.sev.ERROR { color: var(--error); }
.sev.WARNING { color: var(--warning); }
.sev.INFO { color: var(--info); }
.sev.UNVERIFIABLE { color: var(--unverifiable); }
.finding h3 { font-size: 1rem; margin: 0 0 0.4rem; }
.finding dl { margin: 0.4rem 0 0; display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 0.75rem; }
.finding dt { color: var(--muted); }
.finding dd { margin: 0; }
.rule { margin-top: 0.6rem; padding-top: 0.5rem; border-top: 1px dashed var(--border); font-size: 0.9rem; color: var(--muted); }
.rule a { color: inherit; }
/* Every finding carries a Registry URI and a rule source URL, and both
are long unbroken strings. Without this the report pushed the page to
366 CSS px at a 320 px viewport, which is SC 1.4.10 (reflow): a
horizontal scrollbar on the whole document, and content off the right
edge. Measured at 320x256 in headless Chrome; the empty page passed,
so only the state a visitor actually reaches was ever broken.
`anywhere` rather than `break-word` because a 39-character CTID has no
break opportunity in it at all. */
.finding dd,
.finding h3,
.rule,
.rule a { overflow-wrap: anywhere; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--surface); padding: 0 0.25rem; border-radius: 3px; }
.clean { border: 1px solid var(--border); border-left: 5px solid var(--info); border-radius: 6px; padding: 1rem; }
footer { margin-top: 2rem; color: var(--muted); font-size: 0.9rem; }
footer a { color: inherit; }
</style>
</head>
<body>
<main>
<h1>ctdl-validate playground</h1>
<p class="lede">
Structural validation for CTDL JSON-LD, run before you publish to the
Credential Registry. Nothing is uploaded: the validator runs entirely
in your browser via Pyodide (WebAssembly), so unpublished credential
and competency data never leaves your machine.
</p>
<div class="panel">
<h2 id="input-heading" style="margin-top: 0">Payload</h2>
<p style="margin-top: 0; color: var(--muted)">
Load a sample, paste your own JSON-LD, or choose a <code>.json</code> file.
</p>
<div class="samples">
<button type="button" data-sample="clean">Clean certification</button>
<button type="button" data-sample="bareUuid">Bare UUID as CTID</button>
<button type="button" data-sample="framework">Competency framework with broken links</button>
</div>
<label for="payload"><strong>CTDL JSON-LD</strong></label>
<textarea id="payload" spellcheck="false" aria-describedby="status"></textarea>
<div class="actions">
<input type="file" id="file" accept=".json,application/json,application/ld+json" aria-label="Choose a JSON file" />
<button id="run" disabled>Validate</button>
</div>
<p id="status" role="status">Loading Python (this takes a few seconds the first time)…</p>
</div>
<h2 id="report-heading" tabindex="-1">Findings</h2>
<div id="report">
<p style="color: var(--muted)">Results will appear here after you validate a payload.</p>
</div>
</main>
<footer>
<p id="footer-version"></p>
<p>
Every finding cites the rule it came from and the document that rule was
read out of, with the date that document was retrieved. Source and issue
tracker:
<a href="https://github.com/ChelseaKR/ctdl-validate">github.com/ChelseaKR/ctdl-validate</a>.
</p>
</footer>
<script>
// Pyodide runtime. When bumping the version, regenerate the integrity
// hash to match: `curl -sSL <url> | openssl dgst -sha384 -binary |
// openssl base64 -A`, prefixed with `sha384-`.
//
// Injected by boot() rather than written as a <script src> in the
// markup, so that the page makes no network request until someone
// actually wants the validator. That matters twice: ?a11y-static fetches
// nothing at all, which is what lets the accessibility gate run offline
// and deterministically, and a visitor who lands here and reads the page
// without validating anything never touches a CDN either.
const PYODIDE_VERSION = "v0.26.4";
const PYODIDE_SRI =
"sha384-i3R37b3tF+HWudsUf1VSEOY2YxwSNMqY8DQa9Z0O3xh+NkJ9o+yjcGyIi5huj+nB";
function loadPyodideRuntime() {
return new Promise((resolve, reject) => {
const tag = document.createElement("script");
tag.src = `https://cdn.jsdelivr.net/pyodide/${PYODIDE_VERSION}/full/pyodide.js`;
tag.integrity = PYODIDE_SRI;
tag.crossOrigin = "anonymous";
tag.onload = resolve;
tag.onerror = () =>
reject(new Error("the Pyodide runtime could not be loaded from cdn.jsdelivr.net"));
document.head.appendChild(tag);
});
}
const statusEl = document.getElementById("status");
const runBtn = document.getElementById("run");
const payload = document.getElementById("payload");
const fileInput = document.getElementById("file");
const report = document.getElementById("report");
const reportHeading = document.getElementById("report-heading");
const footerVersion = document.getElementById("footer-version");
// Kept verbatim from tests/fixtures/ so the samples stay honest: these
// are the same documents the test suite asserts against, including the
// two bug classes found in real published data.
const SAMPLES = {
clean: {
"@context": "https://credreg.net/ctdl/schema/context/json",
"@id": "https://credentialengineregistry.org/resources/ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"@type": "ceterms:Certification",
"ceterms:ctid": "ce-59e8d15f-7895-4346-a5a8-7a0739a3d344",
"ceterms:name": { "en-US": "Example Widget Safety Certification" },
"ceterms:subjectWebpage": "https://example.org/certifications/widget-safety",
},
bareUuid: {
"@context": "https://credreg.net/ctdl/schema/context/json",
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/b55f88e3-dfd4-430b-ab47-3e5f9986e1e4",
"@type": "ceterms:Certification",
"ceterms:ctid": "b55f88e3-dfd4-430b-ab47-3e5f9986e1e4",
"ceterms:name": { "en-US": "Certification With a Generated UUID Instead of a CTID" },
},
],
},
framework: {
"@context": "https://credreg.net/ctdlasn/schema/context/json",
"@graph": [
{
"@id": "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
"@type": "ceasn:CompetencyFramework",
"ceterms:ctid": "ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
"ceasn:name": { "en-US": "The Framework These Competencies Belong To" },
},
{
"@id": "https://credentialengineregistry.org/resources/ce-5e3de882-3b49-421b-b623-695c63587f4f",
"@type": "ceasn:Competency",
"ceterms:ctid": "ce-5e3de882-3b49-421b-b623-695c63587f4f",
"ceasn:competencyText": { "en-US": "Member competency with the correct isPartOf" },
"ceasn:isPartOf": "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
},
{
"@id": "https://credentialengineregistry.org/resources/ce-9e492574-07fc-4154-b7f2-898425f4f3a3",
"@type": "ceasn:Competency",
"ceterms:ctid": "ce-9e492574-07fc-4154-b7f2-898425f4f3a3",
"ceasn:competencyText": { "en-US": "Member competency whose isPartOf carries some other identifier" },
"ceasn:isPartOf": "https://credentialengineregistry.org/resources/ce-82566cee-17f3-4a6e-8f59-b45273aac457",
},
{
"@id": "https://credentialengineregistry.org/resources/ce-b4da9602-bb7a-4ddc-9426-f119482e32a9",
"@type": "ceasn:Competency",
"ceterms:ctid": "ce-b4da9602-bb7a-4ddc-9426-f119482e32a9",
"ceasn:competencyText": { "en-US": "Member competency whose isPartOf points at another competency" },
"ceasn:isPartOf": "https://credentialengineregistry.org/resources/ce-5e3de882-3b49-421b-b623-695c63587f4f",
},
],
},
};
let pyodide;
let runValidation;
for (const btn of document.querySelectorAll("[data-sample]")) {
btn.addEventListener("click", () => {
payload.value = JSON.stringify(SAMPLES[btn.dataset.sample], null, 2);
statusEl.textContent = "Sample loaded. Click Validate.";
});
}
fileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
payload.value = await file.text();
statusEl.textContent = `Loaded ${file.name}. Click Validate.`;
});
async function boot() {
await loadPyodideRuntime();
pyodide = await loadPyodide();
// The wheel is built from this commit by the Pages workflow and served
// from this origin. The manifest carries its filename so a version bump
// never needs an edit here.
const manifest = await (await fetch("wheel.json")).json();
const wheel = await (await fetch(manifest.wheel)).arrayBuffer();
// A wheel is a zip; unpacking it into the working directory (which is
// already on sys.path) imports it without micropip, and therefore
// without any call to PyPI.
await pyodide.unpackArchive(wheel, "zip");
runValidation = pyodide.runPython(`
import json
from ctdl_validate import validate_document, __version__
from ctdl_validate.graph import DocumentError
def _run(text):
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
return json.dumps({"error": f"That is not valid JSON: {exc}"})
try:
findings = validate_document(data)
except DocumentError as exc:
return json.dumps({"error": f"ctdl-validate cannot read this document shape: {exc}"})
return json.dumps(
{"version": __version__, "findings": [f.to_dict() for f in findings]}
)
_run
`);
const version = pyodide.runPython("__import__('ctdl_validate').__version__");
footerVersion.textContent = `ctdl-validate ${version} · Pyodide ${PYODIDE_VERSION} · running locally in your browser`;
statusEl.textContent = `Ready — validating with ctdl-validate ${version}. Load a sample or paste a payload.`;
runBtn.disabled = false;
}
function escapeHtml(value) {
return String(value).replace(
/[&<>"']/g,
(ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch],
);
}
function renderFinding(f) {
const sev = escapeHtml(f.severity);
return `
<div class="finding ${sev}">
<h3><span class="sev ${sev}">${sev}</span> · <span class="mono">${escapeHtml(f.code)}</span></h3>
<p style="margin:0">${escapeHtml(f.message)}</p>
<dl>
<dt>Entity</dt><dd class="mono">${escapeHtml(f.entity)}</dd>
<dt>Property</dt><dd class="mono">${escapeHtml(f.property)}</dd>
<dt>Value</dt><dd class="mono">${escapeHtml(f.value)}</dd>
</dl>
<p class="rule">
Rule: ${escapeHtml(f.rule.citation)}<br />
Source: <a href="${escapeHtml(f.rule.url)}" rel="noreferrer noopener">${escapeHtml(f.rule.url)}</a>
(retrieved ${escapeHtml(f.rule.retrieved)})
</p>
</div>`;
}
function renderReport(findings) {
if (findings.length === 0) {
report.innerHTML =
'<div class="clean"><p style="margin:0"><strong>No findings.</strong> This payload passes every structural check.</p></div>';
return;
}
const counts = {};
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
const summary = Object.entries(counts)
.map(([sev, n]) => `${n} ${sev.toLowerCase()}`)
.join(", ");
report.innerHTML =
`<p>${findings.length} finding${findings.length === 1 ? "" : "s"}: ${escapeHtml(summary)}.</p>` +
findings.map(renderFinding).join("");
}
runBtn.addEventListener("click", () => {
const text = payload.value.trim();
if (!text) {
statusEl.textContent = "Paste a payload or load a sample first.";
return;
}
statusEl.textContent = "Validating…";
let parsed;
try {
parsed = JSON.parse(runValidation(text));
} catch (err) {
statusEl.textContent = "Validation error: " + err;
return;
}
if (parsed.error) {
report.innerHTML = `<div class="clean"><p style="margin:0">${escapeHtml(parsed.error)}</p></div>`;
statusEl.textContent = "Could not validate.";
reportHeading.focus();
return;
}
renderReport(parsed.findings);
statusEl.textContent = "Done.";
// Move focus to the report heading so keyboard and screen-reader users
// land on the results they just asked for.
reportHeading.focus();
});
// ?a11y-static renders the page in its post-run state without booting
// Pyodide, and it is what CI audits. Two reasons it exists.
//
// The findings list is where nearly all of this page's markup lives and
// it is the only place the four severity colours appear, so a scanner
// that only ever sees the empty page has audited the least interesting
// half. One finding of each severity is rendered here, through the same
// renderFinding() the real run uses, so the colours and the dl/dt/dd
// structure are the ones a visitor gets.
//
// It also makes the audit deterministic and offline. Booting Pyodide
// means a 5.6 MB download from a CDN, so gating on the live page would
// make a merge depend on jsDelivr being up. The startup path is
// therefore checked by a human, not by this gate: see
// docs/RESPONSIBLE-TECH-AUDITS.md.
const A11Y_SAMPLE = [
{
severity: "ERROR",
code: "CTID_BARE_UUID",
message:
"Bare UUID where a CTID belongs: the ce- prefix is missing. Expected grammar: ce- followed by a UUID v4 in 8-4-4-4-12 form.",
entity: "$.@graph[0]",
property: "ceterms:ctid",
value: "b55f88e3-dfd4-430b-ab47-3e5f9986e1e4",
rule: {
citation: 'About the CTID, section "CTID Structure"',
url: "https://credreg.net/ctdl/ctid",
retrieved: "2026-08-06",
},
},
{
severity: "WARNING",
code: "ISPARTOF_FRAMEWORK_MISMATCH",
message:
"This competency's isPartOf identifier matches no CompetencyFramework in this payload, although this run can see one.",
entity: "$.@graph[2]",
property: "ceasn:isPartOf",
value: "https://credentialengineregistry.org/resources/ce-82566cee-17f3-4a6e-8f59-b45273aac457",
rule: {
citation: 'CTDL Handbook, "Competency Frameworks"',
url: "https://credreg.net/ctdl/handbook",
retrieved: "2026-08-06",
},
},
{
severity: "INFO",
code: "INVERSE_ONE_DIRECTION",
message: "One direction of a declared inverse pair is present without the other.",
entity: "$.@graph[1]",
property: "ceasn:isChildOf",
value: "https://credentialengineregistry.org/resources/ce-177f4c85-4efe-401d-acdd-1ea4adeeaf37",
rule: {
citation: "CTDL-ASN schema encoding, owl:inverseOf declarations",
url: "https://credreg.net/ctdlasn/schema/encoding/json",
retrieved: "2026-08-06",
},
},
{
severity: "UNVERIFIABLE",
code: "REF_OUTSIDE_PAYLOAD",
message:
"Reference does not resolve inside this payload. It may exist in the Registry or elsewhere; without fetching it, its existence and class cannot be confirmed or denied.",
entity: "$.@graph[0]",
property: "ceterms:ownedBy",
value: "https://credentialengineregistry.org/resources/ce-79298677-d0e4-4799-853a-a633d9071826",
rule: {
citation: "ctdl-validate policy: no network access at validation time",
url: "https://github.com/ChelseaKR/ctdl-validate#methodology",
retrieved: "-",
},
},
];
if (new URLSearchParams(window.location.search).has("a11y-static")) {
payload.value = JSON.stringify(SAMPLES.framework, null, 2);
renderReport(A11Y_SAMPLE);
statusEl.textContent =
"Static accessibility audit mode: the validator is not loaded and these findings are examples.";
footerVersion.textContent =
"Static accessibility audit mode · the validator is not loaded on this page.";
} else {
boot().catch((err) => {
statusEl.textContent = "Failed to load the validator: " + err;
});
}
</script>
</body>
</html>