1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'DidWebDocumentAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_REDIRECTS = 3;
9const DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024;
10const CANONICAL_DID_CONTEXT = 'https://www.w3.org/ns/did/v1';
11const LEGACY_KEY_FIELDS = ['publicKeyMultibase', 'publicKeyBase58', 'publicKeyPem'];
12const DEPRECATED_KEY_FIELDS = ['publicKeyPem'];
13const KEY_MATERIAL_FIELDS = ['publicKeyJwk', 'publicKeyMultibase', 'publicKeyBase58', 'publicKeyPem'];
14const RELATIONSHIP_NAMES = ['authentication', 'assertionMethod', 'keyAgreement', 'capabilityInvocation', 'capabilityDelegation'];
15
16
17
18
19
20
21
22
23function isPrivateIPv4(ip) {
24 const parts = ip.split('.').map(Number);
25 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
26 const [a, b] = parts;
27 return a === 10
28 || (a === 172 && b >= 16 && b <= 31)
29 || (a === 192 && b === 168)
30 || a === 127
31 || a === 0
32 || (a === 169 && b === 254);
33}
34
35function isPrivateIPv6(ip) {
36 const normalized = ip.toLowerCase();
37 return normalized === '::1'
38 || normalized.startsWith('fc')
39 || normalized.startsWith('fd')
40 || normalized.startsWith('fe80:');
41}
42
43export async function normalizeAndValidateUrl(rawUrl) {
44 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('URL is required');
45 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
46 throw new Error('Only HTTP and HTTPS URLs are supported');
47 }
48
49 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
50 const url = new URL(withScheme);
51 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
52 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
53
54 const hostname = url.hostname.replace(/^\[|\]$/g, '');
55 const literalType = net.isIP(hostname);
56 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
57 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
58
59 const records = literalType ? [{ address: hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
60 for (const record of records) {
61 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
62 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
63 }
64 return url;
65}
66
67
68
69
70
71
72
73
74
75
76
77export function toMethodSpecificId(input) {
78 if (!input || typeof input !== 'string') throw new Error('identifier is required');
79 let value = input.trim();
80 if (value.startsWith('did:web:')) {
81 value = value.slice('did:web:'.length);
82 } else if (value.startsWith('did:')) {
83 throw new Error(`Unsupported DID method; expected did:web, got '${value.split(':')[0]}:${value.split(':')[1] || ''}'`);
84 } else {
85
86
87
88 let stripped = value.replace(/^https?:\/\//i, '');
89 stripped = stripped.replace(/\/+$/, '');
90 const [hostPort, ...pathParts] = stripped.split('/');
91 const encodedHostPort = hostPort.replace(':', '%3A');
92 value = pathParts.length ? `${encodedHostPort}:${pathParts.join(':')}` : encodedHostPort;
93 }
94 if (!value) throw new Error('identifier is empty after normalization');
95 return value;
96}
97
98function percentDecodeSegment(segment) {
99 try {
100 return decodeURIComponent(segment);
101 } catch {
102 throw new Error(`Path segment '${segment}' is not validly percent-encoded`);
103 }
104}
105
106
107
108
109
110
111export function assertSafePathSegment(segment) {
112 if (segment === '' || segment === '.' || segment === '..') {
113 throw new Error(`Rejected path segment '${segment}': empty, '.', and '..' segments are not allowed (path-traversal defense)`);
114 }
115 const decoded = percentDecodeSegment(segment);
116 if (decoded === '.' || decoded === '..' || decoded === '') {
117 throw new Error(`Rejected path segment '${segment}': decodes to a traversal segment (path-traversal defense)`);
118 }
119 if (decoded.includes('/') || decoded.includes('\\')) {
120 throw new Error(`Rejected path segment '${segment}': decodes to contain a path separator (DID-confusion / path-traversal defense)`);
121 }
122 return decoded;
123}
124
125
126
127
128
129
130
131
132
133
134
135export function buildDidWebUrl(methodSpecificId) {
136 if (!methodSpecificId || typeof methodSpecificId !== 'string') throw new Error('method-specific identifier is required');
137
138
139
140
141
142 const originalParts = methodSpecificId.split(':');
143 const hostSegment = originalParts[0];
144 const host = hostSegment.replace(/%3A/gi, ':');
145 const rawPathSegments = originalParts.slice(1);
146
147 if (!host) throw new Error('did:web identifier is missing a domain');
148
149 const safeSegments = rawPathSegments.map((seg) => assertSafePathSegment(seg));
150
151 const url = new URL(`https://${host}`);
152 if (safeSegments.length === 0) {
153 url.pathname = '/.well-known/did.json';
154 } else {
155 url.pathname = `/${safeSegments.map(encodeURIComponent).join('/')}/did.json`;
156 }
157 return url.href;
158}
159
160
161
162
163
164
165export function toDidString(methodSpecificId) {
166 return `did:web:${methodSpecificId}`;
167}
168
169
170
171
172
173async function fetchOnce(targetUrl, timeoutSeconds, maxBodyBytes, redirectsRemaining) {
174 await normalizeAndValidateUrl(targetUrl.href);
175 const controller = new AbortController();
176 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
177 try {
178 const response = await fetch(targetUrl, {
179 method: 'GET',
180 redirect: 'manual',
181 signal: controller.signal,
182 headers: {
183 'user-agent': USER_AGENT,
184 accept: 'application/did+json, application/json;q=0.9, */*;q=0.1',
185 },
186 });
187
188 if ([301, 302, 303, 307, 308].includes(response.status)) {
189 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
190 const location = response.headers.get('location');
191 if (!location) throw new Error('Redirect without Location header');
192 const nextUrl = new URL(location, targetUrl.href);
193 await normalizeAndValidateUrl(nextUrl.href);
194 return fetchOnce(nextUrl, timeoutSeconds, maxBodyBytes, redirectsRemaining - 1);
195 }
196
197 let text = '';
198 try {
199 const buffer = await response.arrayBuffer();
200 text = buffer.byteLength > maxBodyBytes
201 ? new TextDecoder().decode(buffer.slice(0, maxBodyBytes))
202 : new TextDecoder().decode(buffer);
203 } catch {
204 text = '';
205 }
206
207 return {
208 ok: response.ok,
209 status: response.status,
210 finalUrl: response.url || targetUrl.href,
211 contentType: response.headers.get('content-type') || '',
212 https: (response.url || targetUrl.href).startsWith('https://'),
213 body: text,
214 error: null,
215 };
216 } catch (error) {
217 return {
218 ok: false,
219 status: null,
220 finalUrl: targetUrl.href,
221 contentType: '',
222 https: targetUrl.protocol === 'https:',
223 body: '',
224 error: error.message,
225 };
226 } finally {
227 clearTimeout(timeout);
228 }
229}
230
231
232
233
234
235function isPlainObject(value) {
236 return value !== null && typeof value === 'object' && !Array.isArray(value);
237}
238
239export function analyzeContext(doc) {
240 if (!('@context' in doc)) {
241 return { present: false, hasCanonical: true, note: "No '@context' present; document will be processed via plain JSON DID rules (did-core 6.2.2)." };
242 }
243 const ctx = doc['@context'];
244 const values = Array.isArray(ctx) ? ctx : [ctx];
245 const hasCanonical = values.includes(CANONICAL_DID_CONTEXT);
246 if (!hasCanonical) {
247 return { present: true, hasCanonical: false, note: `'@context' is present but does not include the canonical DID context '${CANONICAL_DID_CONTEXT}'.` };
248 }
249 return { present: true, hasCanonical: true, note: "'@context' includes the canonical DID context." };
250}
251
252export function analyzeVerificationMethods(doc) {
253 const entries = [];
254 if (!('verificationMethod' in doc)) {
255 return { present: false, count: 0, entries, allValid: true };
256 }
257 if (!Array.isArray(doc.verificationMethod)) {
258 return { present: true, count: 0, entries, allValid: false, issue: "'verificationMethod' must be an array." };
259 }
260 let allValid = true;
261 doc.verificationMethod.forEach((vm, index) => {
262 const issues = [];
263 if (!isPlainObject(vm)) {
264 issues.push('entry is not an object.');
265 entries.push({ index, id: null, type: null, deprecated: false, issues });
266 allValid = false;
267 return;
268 }
269 if (!vm.id || typeof vm.id !== 'string') issues.push("'id' is required and must be a string.");
270 if (!vm.type || typeof vm.type !== 'string') issues.push("'type' is required and must be a string.");
271 if (!vm.controller || typeof vm.controller !== 'string') issues.push("'controller' is required and must be a string.");
272
273 const presentKeyFields = KEY_MATERIAL_FIELDS.filter((f) => f in vm);
274 let deprecated = false;
275 if (presentKeyFields.length === 0) {
276 issues.push(`exactly one key-material field is required (${KEY_MATERIAL_FIELDS.join(', ')}); none present.`);
277 } else if (presentKeyFields.length > 1) {
278 issues.push(`exactly one key-material field is allowed; found multiple: ${presentKeyFields.join(', ')}.`);
279 } else if (DEPRECATED_KEY_FIELDS.includes(presentKeyFields[0])) {
280 deprecated = true;
281 issues.push(`'${presentKeyFields[0]}' is a deprecated/legacy key-material field.`);
282 } else if (LEGACY_KEY_FIELDS.includes(presentKeyFields[0]) && presentKeyFields[0] !== 'publicKeyPem') {
283 issues.push(`'${presentKeyFields[0]}' is a legacy key-material field; prefer 'publicKeyJwk' or 'publicKeyMultibase'.`);
284 }
285
286 if (issues.length) allValid = false;
287 entries.push({ index, id: vm.id ?? null, type: vm.type ?? null, deprecated, issues });
288 });
289 return { present: true, count: doc.verificationMethod.length, entries, allValid };
290}
291
292function isAbsoluteDidUrl(value) {
293 return typeof value === 'string' && value.startsWith('did:');
294}
295
296export function analyzeRelationships(doc) {
297 const result = {};
298 for (const name of RELATIONSHIP_NAMES) {
299 if (!(name in doc)) {
300 result[name] = { present: false, count: 0, entries: [], allValid: true };
301 continue;
302 }
303 if (!Array.isArray(doc[name])) {
304 result[name] = { present: true, count: 0, entries: [], allValid: false, issue: `'${name}' must be an array.` };
305 continue;
306 }
307 const entries = [];
308 let allValid = true;
309 doc[name].forEach((entry, index) => {
310 const issues = [];
311 if (typeof entry === 'string') {
312 if (!isAbsoluteDidUrl(entry)) {
313 issues.push(`'${entry}' is a top-level relationship reference and should be an absolute DID URL (recommendation, not a hard requirement).`);
314 }
315 } else if (isPlainObject(entry)) {
316 if (!entry.id || typeof entry.id !== 'string') issues.push('embedded verification method object is missing a string id.');
317 } else {
318 issues.push('entry must be a DID URL string or an embedded verification method object.');
319 allValid = false;
320 }
321 if (issues.some((i) => !i.includes('recommendation'))) allValid = allValid;
322 entries.push({ index, value: typeof entry === 'string' ? entry : (entry && entry.id) || null, issues });
323 });
324 result[name] = { present: true, count: doc[name].length, entries, allValid };
325 }
326 return result;
327}
328
329export function analyzeServices(doc) {
330 if (!('service' in doc)) return { present: false, count: 0, entries: [], issues: [] };
331 if (!Array.isArray(doc.service)) return { present: true, count: 0, entries: [], issues: ["'service' must be an array."] };
332 const issues = [];
333 const entries = [];
334 doc.service.forEach((svc, index) => {
335 const entryIssues = [];
336 if (!isPlainObject(svc)) {
337 entryIssues.push('entry is not an object.');
338 } else {
339 if (!svc.id || typeof svc.id !== 'string') entryIssues.push("'id' is required and must be a string.");
340 if (!svc.type || typeof svc.type !== 'string') entryIssues.push("'type' is required and must be a string.");
341 if (!svc.serviceEndpoint) entryIssues.push("'serviceEndpoint' is required.");
342 }
343 if (entryIssues.length) issues.push(`service[${index}]: ${entryIssues.join('; ')}`);
344 entries.push({
345 index,
346 id: (isPlainObject(svc) && svc.id) || null,
347 type: (isPlainObject(svc) && svc.type) || null,
348 serviceEndpoint: (isPlainObject(svc) && svc.serviceEndpoint) || null,
349 issues: entryIssues,
350 });
351 });
352 return { present: true, count: doc.service.length, entries, issues };
353}
354
355
356
357
358
359function evalFetch({ fetchResult }) {
360 if (fetchResult.error) {
361 return { status: 'missing', note: `DID document request failed: ${fetchResult.error}`, recommendation: 'Verify the host is reachable over HTTPS and serves the constructed did.json path.' };
362 }
363 if (fetchResult.status !== 200) {
364 return { status: 'missing', note: `DID document endpoint returned HTTP ${fetchResult.status} instead of 200.`, recommendation: 'Serve a 200 response with the DID document JSON at the constructed path.' };
365 }
366 return { status: 'good', note: 'DID document endpoint responded 200.' };
367}
368
369function evalHttps({ fetchResult }) {
370 if (fetchResult.error) return { status: 'info', note: 'No response to evaluate.' };
371 if (!fetchResult.https) {
372 return { status: 'missing', note: 'DID document was not served over HTTPS.', recommendation: 'Serve the DID document over HTTPS (required by the did:web resolution algorithm).' };
373 }
374 return { status: 'good', note: 'DID document served over HTTPS.' };
375}
376
377function evalJson({ doc, parseError }) {
378 if (doc === null) {
379 return { status: 'missing', note: `DID document response is not valid JSON${parseError ? `: ${parseError}` : ''}.`, recommendation: 'Serve a valid JSON object as the DID document.' };
380 }
381 return { status: 'good', note: 'DID document response is valid JSON.' };
382}
383
384function evalContext({ doc, contextAnalysis }) {
385 if (doc === null) return { status: 'info', note: 'No document to evaluate.' };
386 if (!contextAnalysis.present) return { status: 'good', note: contextAnalysis.note };
387 if (!contextAnalysis.hasCanonical) {
388 return { status: 'warn', note: contextAnalysis.note, recommendation: `Include '${CANONICAL_DID_CONTEXT}' in '@context' when a JSON-LD context is present.` };
389 }
390 return { status: 'good', note: contextAnalysis.note };
391}
392
393function evalId({ doc, resolvedDid }) {
394 if (doc === null) return { status: 'info', note: 'No document to evaluate.' };
395 if (!doc.id || typeof doc.id !== 'string') {
396 return { status: 'missing', note: "'id' field is missing or not a string.", recommendation: "Include an 'id' field equal to the resolved did:web identifier." };
397 }
398 if (doc.id !== resolvedDid) {
399 return { status: 'missing', note: `'id' ('${doc.id}') does not match the resolved identifier ('${resolvedDid}').`, recommendation: "Set 'id' to exactly the did:web identifier being resolved (did:web resolution step 7)." };
400 }
401 return { status: 'good', note: "'id' matches the resolved did:web identifier." };
402}
403
404function evalVerificationMethod({ doc, vmAnalysis }) {
405 if (doc === null) return { status: 'info', note: 'No document to evaluate.' };
406 if (!vmAnalysis.present) {
407 return { status: 'warn', note: "No 'verificationMethod' array present.", recommendation: "Include a 'verificationMethod' array with at least one key-material entry." };
408 }
409 if (vmAnalysis.issue) {
410 return { status: 'missing', note: vmAnalysis.issue, recommendation: "Fix 'verificationMethod' to be an array of verification method objects." };
411 }
412 const bad = vmAnalysis.entries.filter((e) => e.issues.length > 0);
413 if (bad.length) {
414 return { status: 'missing', note: `${bad.length} of ${vmAnalysis.entries.length} verificationMethod entries have issues: ${bad.map((e) => e.issues.join('; ')).join(' | ')}`, recommendation: "Each verificationMethod entry needs 'id', 'type', 'controller', and exactly one key-material field." };
415 }
416 const deprecated = vmAnalysis.entries.filter((e) => e.deprecated);
417 if (deprecated.length) {
418 return { status: 'warn', note: `${deprecated.length} verificationMethod entr(ies) use deprecated key-material fields (publicKeyPem).`, recommendation: "Migrate deprecated 'publicKeyPem' entries to 'publicKeyJwk' or 'publicKeyMultibase'." };
419 }
420 return { status: 'good', note: `${vmAnalysis.entries.length} verificationMethod entr(ies) are well-formed.` };
421}
422
423function evalRelationships({ doc, relAnalysis }) {
424 if (doc === null) return { status: 'info', note: 'No document to evaluate.' };
425 const malformed = [];
426 for (const name of RELATIONSHIP_NAMES) {
427 const rel = relAnalysis[name];
428 if (rel.issue) malformed.push(`${name}: ${rel.issue}`);
429 for (const entry of rel.entries || []) {
430 const hard = entry.issues.filter((i) => !i.includes('recommendation'));
431 if (hard.length && entry.value === null) malformed.push(`${name}[${entry.index}]: ${hard.join('; ')}`);
432 }
433 }
434 if (malformed.length) {
435 return { status: 'missing', note: `Malformed relationship entries: ${malformed.join(' | ')}`, recommendation: 'Each relationship array entry must be a DID URL string or an embedded verification method object with an id.' };
436 }
437 return { status: 'good', note: 'Verification relationship arrays (if present) are well-formed.' };
438}
439
440function evalServices({ doc, serviceAnalysis }) {
441 if (doc === null) return { status: 'info', note: 'No document to evaluate.' };
442 if (!serviceAnalysis.present) return { status: 'good', note: "No 'service' array present (optional)." };
443 if (serviceAnalysis.issues.length) {
444 return { status: 'missing', note: `Service array issues: ${serviceAnalysis.issues.join(' | ')}`, recommendation: "Each service entry needs 'id', 'type', and 'serviceEndpoint'." };
445 }
446 return { status: 'good', note: `${serviceAnalysis.count} service entr(ies) are well-formed.` };
447}
448
449const CHECKS = [
450 { name: 'endpoint', title: 'DID document endpoint response', weight: 20, fn: evalFetch },
451 { name: 'https', title: 'HTTPS transport', weight: 15, fn: evalHttps },
452 { name: 'json', title: 'JSON validity', weight: 15, fn: evalJson },
453 { name: 'context', title: '@context canonical DID context', weight: 5, fn: evalContext },
454 { name: 'id', title: 'id matches resolved DID', weight: 20, fn: evalId },
455 { name: 'verificationMethod', title: 'verificationMethod entries', weight: 15, fn: evalVerificationMethod },
456 { name: 'relationships', title: 'Verification relationships', weight: 5, fn: evalRelationships },
457 { name: 'service', title: 'Service array', weight: 5, fn: evalServices },
458];
459
460export function buildCheckReports(context) {
461 const reports = [];
462 let earned = 0;
463 let possible = 0;
464 for (const check of CHECKS) {
465 const evaluation = check.fn(context);
466 if (evaluation.status !== 'info') possible += check.weight;
467 if (evaluation.status === 'good') earned += check.weight;
468 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
469 reports.push({
470 name: check.title,
471 check: check.name,
472 status: evaluation.status,
473 note: evaluation.note,
474 weight: check.weight,
475 recommendation: evaluation.recommendation || null,
476 });
477 }
478 return { reports, earned, possible };
479}
480
481export function scoreAudit(earned, possible) {
482 if (possible === 0) return 0;
483 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
484}
485
486export function gradeFromScore(score) {
487 if (score >= 90) return 'A';
488 if (score >= 80) return 'B';
489 if (score >= 70) return 'C';
490 if (score >= 60) return 'D';
491 return 'F';
492}
493
494export function buildRecommendations(reports) {
495 const recs = [];
496 for (const r of reports) {
497 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
498 }
499 if (recs.length === 0) {
500 recs.push('did:web document looks spec-conformant. Re-run this audit after document updates to catch regressions.');
501 }
502 return recs;
503}
504
505
506
507
508
509export async function auditDidWeb(input, identifierInput) {
510 const timeoutSeconds = Math.min(Math.max(Number(input.timeout || DEFAULT_TIMEOUT_SECONDS), 3), 60);
511 const maxRedirects = Math.min(Math.max(Number.isFinite(Number(input.maxRedirects)) ? Number(input.maxRedirects) : DEFAULT_MAX_REDIRECTS, 0), 5);
512 const maxBodyBytes = Math.min(Math.max(Number(input.maxBodyBytes || DEFAULT_MAX_BODY_BYTES), 1024), 10 * 1024 * 1024);
513
514 const checkedAt = new Date().toISOString();
515 let methodSpecificId;
516 let resolvedUrl;
517 let resolvedDid;
518 try {
519 methodSpecificId = toMethodSpecificId(identifierInput);
520 resolvedUrl = buildDidWebUrl(methodSpecificId);
521 resolvedDid = toDidString(methodSpecificId);
522 } catch (err) {
523 return {
524 inputIdentifier: identifierInput,
525 resolvedDid: null,
526 resolvedUrl: null,
527 finalUrl: null,
528 https: null,
529 status: null,
530 contentType: null,
531 jsonValid: false,
532 parseError: null,
533 idMatch: false,
534 context: null,
535 verificationMethod: null,
536 relationships: null,
537 serviceCount: 0,
538 serviceIssues: [],
539 services: [],
540 score: 0,
541 grade: 'F',
542 checks: [],
543 issues: [err.message],
544 recommendations: ['Fix the did:web identifier so a DID document URL can be constructed.'],
545 checkedAt,
546 error: err.message,
547 };
548 }
549
550
551
552 let fetchResult;
553 try {
554 await normalizeAndValidateUrl(resolvedUrl);
555 fetchResult = await fetchOnce(new URL(resolvedUrl), timeoutSeconds, maxBodyBytes, maxRedirects);
556 } catch (err) {
557 fetchResult = { ok: false, status: null, finalUrl: resolvedUrl, contentType: '', https: resolvedUrl.startsWith('https://'), body: '', error: err.message };
558 }
559
560 let doc = null;
561 let parseError = null;
562 if (fetchResult.body) {
563 try {
564 doc = JSON.parse(fetchResult.body);
565 if (!isPlainObject(doc)) {
566 parseError = 'DID document must be a JSON object';
567 doc = null;
568 }
569 } catch (err) {
570 parseError = err.message;
571 }
572 }
573
574 const contextAnalysis = doc ? analyzeContext(doc) : { present: false, hasCanonical: true, note: 'No document to evaluate.' };
575 const vmAnalysis = doc ? analyzeVerificationMethods(doc) : { present: false, count: 0, entries: [], allValid: true };
576 const relAnalysis = doc ? analyzeRelationships(doc) : Object.fromEntries(RELATIONSHIP_NAMES.map((n) => [n, { present: false, count: 0, entries: [], allValid: true }]));
577 const serviceAnalysis = doc ? analyzeServices(doc) : { present: false, count: 0, entries: [], issues: [] };
578 const idMatch = !!(doc && doc.id === resolvedDid);
579
580 const context = { doc, parseError, fetchResult, contextAnalysis, vmAnalysis, relAnalysis, serviceAnalysis, resolvedDid };
581 const { reports, earned, possible } = buildCheckReports(context);
582 const score = scoreAudit(earned, possible);
583 const grade = gradeFromScore(score);
584 const recommendations = buildRecommendations(reports);
585
586 const issues = reports
587 .filter((r) => r.status === 'warn' || r.status === 'missing')
588 .map((r) => `${r.name}: ${r.note}`);
589
590 return {
591 inputIdentifier: identifierInput,
592 resolvedDid,
593 resolvedUrl,
594 finalUrl: fetchResult.finalUrl,
595 https: fetchResult.https,
596 status: fetchResult.status,
597 contentType: fetchResult.contentType || null,
598 jsonValid: doc !== null,
599 parseError,
600 idMatch,
601 context: contextAnalysis,
602 verificationMethod: vmAnalysis,
603 relationships: relAnalysis,
604 serviceCount: serviceAnalysis.count,
605 serviceIssues: serviceAnalysis.issues,
606 services: serviceAnalysis.entries,
607 score,
608 grade,
609 checks: reports,
610 issues,
611 recommendations,
612 checkedAt,
613 error: fetchResult.error,
614 };
615}
616
617
618
619
620
621const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
622
623if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
624 await Actor.init();
625 try {
626 const input = await Actor.getInput();
627 const identifiers = Array.isArray(input?.identifiers) ? input.identifiers.slice(0, 50) : [];
628 if (identifiers.length === 0) throw new Error("Input 'identifiers' must be a non-empty array of did:web identifiers or domains.");
629 for (const identifier of identifiers) {
630 const result = await auditDidWeb(input || {}, identifier);
631 await Actor.pushData(result);
632 Actor.log.info('did:web audit complete', { identifier, resolvedDid: result.resolvedDid, score: result.score, grade: result.grade });
633 }
634 } finally {
635 await Actor.exit();
636 }
637}