@reddoorla/maintenance 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/bin.js +722 -50
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -5
- package/dist/cli/commands/audit.js +532 -16
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/forms/index.js +42 -2
- package/dist/forms/index.js.map +1 -1
- package/dist/index.d.ts +109 -3
- package/dist/index.js +711 -41
- package/dist/index.js.map +1 -1
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{types-DeKpgkG-.d.ts → types-QG-QhCYh.d.ts} +1 -1
- package/package.json +1 -1
package/dist/cli/bin.js
CHANGED
|
@@ -71,12 +71,54 @@ function isHttpUrl(s) {
|
|
|
71
71
|
}
|
|
72
72
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
73
73
|
}
|
|
74
|
+
function isNetlifyAppUrl(s) {
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = new URL(s);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const host = parsed.hostname.toLowerCase();
|
|
82
|
+
return host === "netlify.app" || host.endsWith(".netlify.app");
|
|
83
|
+
}
|
|
74
84
|
var init_url = __esm({
|
|
75
85
|
"src/util/url.ts"() {
|
|
76
86
|
"use strict";
|
|
77
87
|
}
|
|
78
88
|
});
|
|
79
89
|
|
|
90
|
+
// src/reports/airtable/throttle.ts
|
|
91
|
+
function createMinIntervalThrottle(opts) {
|
|
92
|
+
const { minIntervalMs, now, delay } = opts;
|
|
93
|
+
return function wrap(fn) {
|
|
94
|
+
let chain = Promise.resolve();
|
|
95
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
96
|
+
return (...args) => {
|
|
97
|
+
chain = chain.then(async () => {
|
|
98
|
+
const wait = minIntervalMs - (now() - last);
|
|
99
|
+
if (wait > 0) await delay(wait);
|
|
100
|
+
last = now();
|
|
101
|
+
fn(...args);
|
|
102
|
+
}).catch(() => {
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function applyThrottle(base, opts) {
|
|
108
|
+
const real = base._base?.runAction;
|
|
109
|
+
if (typeof real !== "function") return base;
|
|
110
|
+
const wrap = createMinIntervalThrottle(opts);
|
|
111
|
+
const throttled = wrap(real.bind(base._base));
|
|
112
|
+
base._base.runAction = throttled;
|
|
113
|
+
base.runAction = throttled;
|
|
114
|
+
return base;
|
|
115
|
+
}
|
|
116
|
+
var init_throttle = __esm({
|
|
117
|
+
"src/reports/airtable/throttle.ts"() {
|
|
118
|
+
"use strict";
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
80
122
|
// src/reports/airtable/client.ts
|
|
81
123
|
var client_exports = {};
|
|
82
124
|
__export(client_exports, {
|
|
@@ -100,12 +142,20 @@ function readAirtableConfig() {
|
|
|
100
142
|
return { apiKey, baseId };
|
|
101
143
|
}
|
|
102
144
|
function openBase(cfg) {
|
|
103
|
-
|
|
145
|
+
const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
|
|
146
|
+
return applyThrottle(base, {
|
|
147
|
+
minIntervalMs: MIN_REQUEST_INTERVAL_MS,
|
|
148
|
+
now: () => Date.now(),
|
|
149
|
+
delay: (ms) => new Promise((resolve12) => setTimeout(resolve12, ms))
|
|
150
|
+
});
|
|
104
151
|
}
|
|
152
|
+
var MIN_REQUEST_INTERVAL_MS;
|
|
105
153
|
var init_client = __esm({
|
|
106
154
|
"src/reports/airtable/client.ts"() {
|
|
107
155
|
"use strict";
|
|
108
156
|
init_credentials();
|
|
157
|
+
init_throttle();
|
|
158
|
+
MIN_REQUEST_INTERVAL_MS = 220;
|
|
109
159
|
}
|
|
110
160
|
});
|
|
111
161
|
|
|
@@ -113,12 +163,15 @@ var init_client = __esm({
|
|
|
113
163
|
var websites_exports = {};
|
|
114
164
|
__export(websites_exports, {
|
|
115
165
|
ACTIVE_STATUSES: () => ACTIVE_STATUSES,
|
|
166
|
+
SEVERITY_RANK: () => SEVERITY_RANK,
|
|
116
167
|
WEBSITES_TABLE: () => WEBSITES_TABLE,
|
|
117
168
|
getWebsiteBySlug: () => getWebsiteBySlug,
|
|
118
169
|
isDashboardVisible: () => isDashboardVisible,
|
|
119
170
|
listWebsites: () => listWebsites,
|
|
120
171
|
mapRow: () => mapRow,
|
|
172
|
+
normalizeSecurityAdvisory: () => normalizeSecurityAdvisory,
|
|
121
173
|
parseNotifyRouting: () => parseNotifyRouting,
|
|
174
|
+
parseSecurityAdvisories: () => parseSecurityAdvisories,
|
|
122
175
|
siteSlug: () => siteSlug,
|
|
123
176
|
updateA11yCounts: () => updateA11yCounts,
|
|
124
177
|
updateAuditFields: () => updateAuditFields,
|
|
@@ -198,6 +251,15 @@ function mapRow(rec) {
|
|
|
198
251
|
securityVulnsHigh: f["Security Vulns High"] ?? null,
|
|
199
252
|
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
200
253
|
securityVulnsLow: f["Security Vulns Low"] ?? null,
|
|
254
|
+
lastSecurityAuditAt: f["Last security audit at"] ?? null,
|
|
255
|
+
securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
|
|
256
|
+
certDaysRemaining: f["Cert days remaining"] ?? null,
|
|
257
|
+
domainCheckedAt: f["Domain checked at"] ?? null,
|
|
258
|
+
crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
|
|
259
|
+
mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
|
|
260
|
+
linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
|
|
261
|
+
brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
|
|
262
|
+
browserCheckedAt: f["Browser checked at"] ?? null,
|
|
201
263
|
copyIntro: trimToNull(f["Copy \u2014 Intro"]),
|
|
202
264
|
copyContact: trimToNull(f["Copy \u2014 Contact"]),
|
|
203
265
|
copyFooter: trimToNull(f["Copy \u2014 Footer"]),
|
|
@@ -254,12 +316,61 @@ function depsFields(counts) {
|
|
|
254
316
|
}
|
|
255
317
|
return fields;
|
|
256
318
|
}
|
|
319
|
+
function normalizeSecurityAdvisory(raw) {
|
|
320
|
+
if (!raw || typeof raw !== "object") return null;
|
|
321
|
+
const e = raw;
|
|
322
|
+
const module = typeof e["module"] === "string" ? e["module"] : null;
|
|
323
|
+
const severity = e["severity"];
|
|
324
|
+
if (module === null) return null;
|
|
325
|
+
if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
|
|
326
|
+
return null;
|
|
327
|
+
const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
|
|
328
|
+
return {
|
|
329
|
+
module,
|
|
330
|
+
severity,
|
|
331
|
+
title: typeof e["title"] === "string" ? e["title"] : "",
|
|
332
|
+
cves,
|
|
333
|
+
url: typeof e["url"] === "string" ? e["url"] : null
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function parseSecurityAdvisories(raw) {
|
|
337
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(raw);
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
if (!Array.isArray(parsed)) return null;
|
|
345
|
+
return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
|
|
346
|
+
}
|
|
347
|
+
function securityAdvisoryFields(advisories) {
|
|
348
|
+
const capped = [...advisories].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]).slice(0, MAX_PERSISTED_ADVISORIES);
|
|
349
|
+
return { "Security advisories": JSON.stringify(capped) };
|
|
350
|
+
}
|
|
257
351
|
function securityFields(counts) {
|
|
258
352
|
return {
|
|
259
353
|
"Security Vulns Critical": counts.critical,
|
|
260
354
|
"Security Vulns High": counts.high,
|
|
261
355
|
"Security Vulns Moderate": counts.moderate,
|
|
262
|
-
"Security Vulns Low": counts.low
|
|
356
|
+
"Security Vulns Low": counts.low,
|
|
357
|
+
// Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent
|
|
358
|
+
// audit (a clean count from months ago must not silently keep ticking the box).
|
|
359
|
+
"Last security audit at": (/* @__PURE__ */ new Date()).toISOString()
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function domainFields(result) {
|
|
363
|
+
const fields = { "Domain checked at": result.checkedAt };
|
|
364
|
+
if (result.certDaysRemaining !== null) fields["Cert days remaining"] = result.certDaysRemaining;
|
|
365
|
+
return fields;
|
|
366
|
+
}
|
|
367
|
+
function browserFields(r) {
|
|
368
|
+
return {
|
|
369
|
+
"Crossbrowser OK": r.desktopOk,
|
|
370
|
+
"Mobile OK": r.mobileOk,
|
|
371
|
+
"Links OK": r.linksOk,
|
|
372
|
+
"Broken links": r.brokenLinks,
|
|
373
|
+
"Browser checked at": r.checkedAt
|
|
263
374
|
};
|
|
264
375
|
}
|
|
265
376
|
async function updateScores(base, recordId, scores) {
|
|
@@ -280,6 +391,10 @@ async function updateAuditFields(base, recordId, audits) {
|
|
|
280
391
|
if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
|
|
281
392
|
if (audits.deps) Object.assign(fields, depsFields(audits.deps));
|
|
282
393
|
if (audits.security) Object.assign(fields, securityFields(audits.security));
|
|
394
|
+
if (audits.securityAdvisories)
|
|
395
|
+
Object.assign(fields, securityAdvisoryFields(audits.securityAdvisories));
|
|
396
|
+
if (audits.domain) Object.assign(fields, domainFields(audits.domain));
|
|
397
|
+
if (audits.browser) Object.assign(fields, browserFields(audits.browser));
|
|
283
398
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
284
399
|
return fields;
|
|
285
400
|
}
|
|
@@ -298,7 +413,7 @@ async function updateLaunched(base, recordId, at) {
|
|
|
298
413
|
const fields = { Status: "maintenance", "Launched at": at };
|
|
299
414
|
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
300
415
|
}
|
|
301
|
-
var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
|
|
416
|
+
var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES, SEVERITY_RANK, MAX_PERSISTED_ADVISORIES;
|
|
302
417
|
var init_websites = __esm({
|
|
303
418
|
"src/reports/airtable/websites.ts"() {
|
|
304
419
|
"use strict";
|
|
@@ -308,6 +423,13 @@ var init_websites = __esm({
|
|
|
308
423
|
"launch period"
|
|
309
424
|
]);
|
|
310
425
|
FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
|
|
426
|
+
SEVERITY_RANK = {
|
|
427
|
+
critical: 0,
|
|
428
|
+
high: 1,
|
|
429
|
+
moderate: 2,
|
|
430
|
+
low: 3
|
|
431
|
+
};
|
|
432
|
+
MAX_PERSISTED_ADVISORIES = 25;
|
|
311
433
|
}
|
|
312
434
|
});
|
|
313
435
|
|
|
@@ -468,9 +590,66 @@ function securityCountsFromResult(result) {
|
|
|
468
590
|
const c = details?.counts ?? { low: 0, moderate: 0, high: 0, critical: 0 };
|
|
469
591
|
return { critical: c.critical, high: c.high, moderate: c.moderate, low: c.low };
|
|
470
592
|
}
|
|
593
|
+
function advisoriesFromResult(result) {
|
|
594
|
+
if (result.audit !== "security") {
|
|
595
|
+
throw new Error(`Expected a 'security' AuditResult, got '${result.audit}'`);
|
|
596
|
+
}
|
|
597
|
+
const details = result.details;
|
|
598
|
+
const raw = details?.advisories;
|
|
599
|
+
if (!Array.isArray(raw)) return [];
|
|
600
|
+
return raw.map(normalizeSecurityAdvisory).filter((a) => a !== null);
|
|
601
|
+
}
|
|
471
602
|
var init_security_airtable = __esm({
|
|
472
603
|
"src/audits/security-airtable.ts"() {
|
|
473
604
|
"use strict";
|
|
605
|
+
init_websites();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// src/audits/domain-airtable.ts
|
|
610
|
+
function hasDomainResult(result) {
|
|
611
|
+
if (result.audit !== "domain") return false;
|
|
612
|
+
const d = result.details;
|
|
613
|
+
return !!d && typeof d.checkedAt === "string";
|
|
614
|
+
}
|
|
615
|
+
function domainResultFromAudit(result) {
|
|
616
|
+
if (result.audit !== "domain") {
|
|
617
|
+
throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);
|
|
618
|
+
}
|
|
619
|
+
const d = result.details;
|
|
620
|
+
return {
|
|
621
|
+
certDaysRemaining: typeof d?.certDaysRemaining === "number" ? d.certDaysRemaining : null,
|
|
622
|
+
checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
var init_domain_airtable = __esm({
|
|
626
|
+
"src/audits/domain-airtable.ts"() {
|
|
627
|
+
"use strict";
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// src/audits/browser-airtable.ts
|
|
632
|
+
function hasBrowserResult(result) {
|
|
633
|
+
if (result.audit !== "browser") return false;
|
|
634
|
+
const d = result.details;
|
|
635
|
+
return !!d && typeof d.checkedAt === "string";
|
|
636
|
+
}
|
|
637
|
+
function browserFieldsFromAudit(result) {
|
|
638
|
+
if (result.audit !== "browser") {
|
|
639
|
+
throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);
|
|
640
|
+
}
|
|
641
|
+
const d = result.details;
|
|
642
|
+
return {
|
|
643
|
+
desktopOk: d?.desktopOk === true,
|
|
644
|
+
mobileOk: d?.mobileOk === true,
|
|
645
|
+
linksOk: d?.linksOk === true,
|
|
646
|
+
brokenLinks: typeof d?.brokenLinks === "number" ? d.brokenLinks : 0,
|
|
647
|
+
checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
var init_browser_airtable = __esm({
|
|
651
|
+
"src/audits/browser-airtable.ts"() {
|
|
652
|
+
"use strict";
|
|
474
653
|
}
|
|
475
654
|
});
|
|
476
655
|
|
|
@@ -484,22 +663,14 @@ __export(write_audits_to_airtable_exports, {
|
|
|
484
663
|
async function writeAuditsToAirtable(args) {
|
|
485
664
|
const { base, websites, slug, results } = args;
|
|
486
665
|
const lhResult = results.find((r) => r.audit === "lighthouse");
|
|
487
|
-
if (!lhResult) {
|
|
488
|
-
throw Object.assign(
|
|
489
|
-
new Error(
|
|
490
|
-
"--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
|
|
491
|
-
),
|
|
492
|
-
{ exitCode: 2 }
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
666
|
const target = websites.find((w) => siteSlug(w.name) === slug);
|
|
496
667
|
if (!target) {
|
|
497
668
|
throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
|
|
498
669
|
}
|
|
499
670
|
const writes = [];
|
|
500
671
|
const audits = {};
|
|
501
|
-
const lhHasScores = hasRealScores(lhResult);
|
|
502
|
-
if (lhHasScores) {
|
|
672
|
+
const lhHasScores = lhResult ? hasRealScores(lhResult) : false;
|
|
673
|
+
if (lhResult && lhHasScores) {
|
|
503
674
|
const scores = lighthouseScoresFromResult(lhResult);
|
|
504
675
|
audits.scores = scores;
|
|
505
676
|
writes.push({ audit: "lighthouse", counts: scores });
|
|
@@ -520,12 +691,25 @@ async function writeAuditsToAirtable(args) {
|
|
|
520
691
|
if (sec && hasSecurityCounts(sec)) {
|
|
521
692
|
const counts = securityCountsFromResult(sec);
|
|
522
693
|
audits.security = counts;
|
|
694
|
+
audits.securityAdvisories = advisoriesFromResult(sec);
|
|
523
695
|
writes.push({ audit: "security", counts });
|
|
524
696
|
}
|
|
697
|
+
const dom = results.find((r) => r.audit === "domain");
|
|
698
|
+
if (dom && hasDomainResult(dom)) {
|
|
699
|
+
const result = domainResultFromAudit(dom);
|
|
700
|
+
audits.domain = result;
|
|
701
|
+
writes.push({ audit: "domain", counts: result });
|
|
702
|
+
}
|
|
703
|
+
const browser = results.find((r) => r.audit === "browser");
|
|
704
|
+
if (browser && hasBrowserResult(browser)) {
|
|
705
|
+
const fields = browserFieldsFromAudit(browser);
|
|
706
|
+
audits.browser = fields;
|
|
707
|
+
writes.push({ audit: "browser", counts: fields });
|
|
708
|
+
}
|
|
525
709
|
if (Object.keys(audits).length > 0) {
|
|
526
710
|
await updateAuditFields(base, target.id, audits);
|
|
527
711
|
}
|
|
528
|
-
if (!lhHasScores) {
|
|
712
|
+
if (lhResult && !lhHasScores) {
|
|
529
713
|
const persisted = writes.map((w) => w.audit);
|
|
530
714
|
throw Object.assign(
|
|
531
715
|
new Error(
|
|
@@ -576,6 +760,8 @@ var init_write_audits_to_airtable = __esm({
|
|
|
576
760
|
init_a11y_airtable();
|
|
577
761
|
init_deps_airtable();
|
|
578
762
|
init_security_airtable();
|
|
763
|
+
init_domain_airtable();
|
|
764
|
+
init_browser_airtable();
|
|
579
765
|
}
|
|
580
766
|
});
|
|
581
767
|
|
|
@@ -661,9 +847,32 @@ function mapRow2(rec) {
|
|
|
661
847
|
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
662
848
|
renderedHtmlAttachment: html,
|
|
663
849
|
resendMessageId: f["Resend message ID"] ?? null,
|
|
664
|
-
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
|
|
850
|
+
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
851
|
+
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
|
|
665
852
|
};
|
|
666
853
|
}
|
|
854
|
+
function parseAutoEvidence(raw) {
|
|
855
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
856
|
+
let parsed;
|
|
857
|
+
try {
|
|
858
|
+
parsed = JSON.parse(raw);
|
|
859
|
+
} catch {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
863
|
+
const out = {};
|
|
864
|
+
for (const [field, v] of Object.entries(parsed)) {
|
|
865
|
+
if (!v || typeof v !== "object") continue;
|
|
866
|
+
const o = v;
|
|
867
|
+
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
|
|
868
|
+
out[field] = {
|
|
869
|
+
result: o.result,
|
|
870
|
+
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
871
|
+
note: typeof o.note === "string" ? o.note : ""
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
875
|
+
}
|
|
667
876
|
function lighthouseFromFields(f) {
|
|
668
877
|
const p = f["Lighthouse \u2014 Performance"];
|
|
669
878
|
const a = f["Lighthouse \u2014 Accessibility"];
|
|
@@ -700,6 +909,10 @@ async function createDraft(base, input) {
|
|
|
700
909
|
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
701
910
|
if (input.period !== void 0) fields["Period"] = input.period;
|
|
702
911
|
if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
|
|
912
|
+
for (const field of input.checklistTicks ?? []) fields[field] = true;
|
|
913
|
+
if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
|
|
914
|
+
fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
|
|
915
|
+
}
|
|
703
916
|
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
704
917
|
const rec = created[0];
|
|
705
918
|
if (!rec) throw new Error("Airtable create returned no records");
|
|
@@ -1470,7 +1683,7 @@ function gitHubSignalsStale(swept, now) {
|
|
|
1470
1683
|
if (swept === null) return true;
|
|
1471
1684
|
const ageMs = now.getTime() - Date.parse(swept);
|
|
1472
1685
|
if (!Number.isFinite(ageMs)) return true;
|
|
1473
|
-
return ageMs > GITHUB_SIGNALS_STALE_DAYS *
|
|
1686
|
+
return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY4;
|
|
1474
1687
|
}
|
|
1475
1688
|
function collectVulnAlerts(sites, baseUrl) {
|
|
1476
1689
|
const items = [];
|
|
@@ -1564,13 +1777,13 @@ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
|
|
|
1564
1777
|
}
|
|
1565
1778
|
return items;
|
|
1566
1779
|
}
|
|
1567
|
-
var GITHUB_SIGNALS_STALE_DAYS,
|
|
1780
|
+
var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
|
|
1568
1781
|
var init_digest_collectors = __esm({
|
|
1569
1782
|
"src/alerts/digest-collectors.ts"() {
|
|
1570
1783
|
"use strict";
|
|
1571
1784
|
init_websites();
|
|
1572
1785
|
GITHUB_SIGNALS_STALE_DAYS = 3;
|
|
1573
|
-
|
|
1786
|
+
MS_PER_DAY4 = 24 * 60 * 60 * 1e3;
|
|
1574
1787
|
LIGHTHOUSE_FLOOR = 75;
|
|
1575
1788
|
LIGHTHOUSE_CATEGORIES2 = [
|
|
1576
1789
|
{ field: "pScore", slug: "performance", label: "Performance" },
|
|
@@ -2015,35 +2228,33 @@ async function sendOne(client, base, site, report) {
|
|
|
2015
2228
|
});
|
|
2016
2229
|
const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
|
|
2017
2230
|
const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
|
|
2231
|
+
const attachments = [
|
|
2232
|
+
toInlineAttachment({
|
|
2233
|
+
bytes: header.bytes,
|
|
2234
|
+
filename: `${cidName}.jpg`,
|
|
2235
|
+
contentType: header.contentType,
|
|
2236
|
+
cid: cidName
|
|
2237
|
+
})
|
|
2238
|
+
];
|
|
2239
|
+
for (const img of [bundled.check, bundled.blurred]) {
|
|
2240
|
+
if (html.includes(`cid:${img.cid}`)) {
|
|
2241
|
+
attachments.push(
|
|
2242
|
+
toInlineAttachment({
|
|
2243
|
+
bytes: img.bytes,
|
|
2244
|
+
filename: img.filename,
|
|
2245
|
+
contentType: img.contentType,
|
|
2246
|
+
cid: img.cid
|
|
2247
|
+
})
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2018
2251
|
const payload = {
|
|
2019
2252
|
from: FROM_ADDRESS2,
|
|
2020
2253
|
to,
|
|
2021
2254
|
replyTo: REPLY_TO,
|
|
2022
2255
|
subject,
|
|
2023
2256
|
html,
|
|
2024
|
-
attachments
|
|
2025
|
-
toInlineAttachment({
|
|
2026
|
-
bytes: header.bytes,
|
|
2027
|
-
filename: `${cidName}.jpg`,
|
|
2028
|
-
contentType: header.contentType,
|
|
2029
|
-
cid: cidName
|
|
2030
|
-
}),
|
|
2031
|
-
// Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
|
|
2032
|
-
// in the template. Attached inline so the email is self-contained — no
|
|
2033
|
-
// external CDN dependency, no image-blocked broken icons in webmail.
|
|
2034
|
-
toInlineAttachment({
|
|
2035
|
-
bytes: bundled.check.bytes,
|
|
2036
|
-
filename: bundled.check.filename,
|
|
2037
|
-
contentType: bundled.check.contentType,
|
|
2038
|
-
cid: bundled.check.cid
|
|
2039
|
-
}),
|
|
2040
|
-
toInlineAttachment({
|
|
2041
|
-
bytes: bundled.blurred.bytes,
|
|
2042
|
-
filename: bundled.blurred.filename,
|
|
2043
|
-
contentType: bundled.blurred.contentType,
|
|
2044
|
-
cid: bundled.blurred.cid
|
|
2045
|
-
})
|
|
2046
|
-
],
|
|
2257
|
+
attachments,
|
|
2047
2258
|
// Stable across retries of the same row — if Airtable stamping fails after a
|
|
2048
2259
|
// successful Resend, the next --send-ready replays with the same key and
|
|
2049
2260
|
// Resend returns the original message id rather than sending a duplicate.
|
|
@@ -3073,13 +3284,348 @@ async function a11yAudit(ctx) {
|
|
|
3073
3284
|
}
|
|
3074
3285
|
}
|
|
3075
3286
|
|
|
3287
|
+
// src/audits/domain.ts
|
|
3288
|
+
import { promises as dnsPromises } from "dns";
|
|
3289
|
+
import tls from "tls";
|
|
3290
|
+
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
3291
|
+
async function checkDomain(url, deps) {
|
|
3292
|
+
let host;
|
|
3293
|
+
try {
|
|
3294
|
+
host = new URL(url).hostname;
|
|
3295
|
+
} catch {
|
|
3296
|
+
return { resolved: false, certDaysRemaining: null };
|
|
3297
|
+
}
|
|
3298
|
+
try {
|
|
3299
|
+
await deps.lookup(host);
|
|
3300
|
+
} catch {
|
|
3301
|
+
return { resolved: false, certDaysRemaining: null };
|
|
3302
|
+
}
|
|
3303
|
+
let validTo;
|
|
3304
|
+
try {
|
|
3305
|
+
validTo = await deps.certValidTo(host);
|
|
3306
|
+
} catch {
|
|
3307
|
+
validTo = null;
|
|
3308
|
+
}
|
|
3309
|
+
if (!validTo || Number.isNaN(validTo.getTime()))
|
|
3310
|
+
return { resolved: true, certDaysRemaining: null };
|
|
3311
|
+
return {
|
|
3312
|
+
resolved: true,
|
|
3313
|
+
certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
function defaultDomainDeps(now) {
|
|
3317
|
+
return {
|
|
3318
|
+
lookup: async (host) => {
|
|
3319
|
+
await dnsPromises.lookup(host);
|
|
3320
|
+
},
|
|
3321
|
+
certValidTo: (host) => new Promise((resolvePromise) => {
|
|
3322
|
+
const socket = tls.connect(
|
|
3323
|
+
{ host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
|
|
3324
|
+
() => {
|
|
3325
|
+
const cert = socket.authorized ? socket.getPeerCertificate() : null;
|
|
3326
|
+
socket.end();
|
|
3327
|
+
const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
|
|
3328
|
+
resolvePromise(validTo);
|
|
3329
|
+
}
|
|
3330
|
+
);
|
|
3331
|
+
socket.on("error", () => resolvePromise(null));
|
|
3332
|
+
socket.on("timeout", () => {
|
|
3333
|
+
socket.destroy();
|
|
3334
|
+
resolvePromise(null);
|
|
3335
|
+
});
|
|
3336
|
+
}),
|
|
3337
|
+
now
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
async function domainAudit(ctx) {
|
|
3341
|
+
const { site } = ctx;
|
|
3342
|
+
const label = siteLabel(site);
|
|
3343
|
+
if (!site.deployedUrl) {
|
|
3344
|
+
return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
|
|
3345
|
+
}
|
|
3346
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
3347
|
+
const deps = ctx.domainDeps ?? defaultDomainDeps(now);
|
|
3348
|
+
const check = await checkDomain(site.deployedUrl, deps);
|
|
3349
|
+
const checkedAt = now.toISOString();
|
|
3350
|
+
const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
|
|
3351
|
+
const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
|
|
3352
|
+
return {
|
|
3353
|
+
audit: "domain",
|
|
3354
|
+
site: label,
|
|
3355
|
+
status,
|
|
3356
|
+
summary,
|
|
3357
|
+
details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
|
|
3358
|
+
};
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
// src/audits/route-discovery.ts
|
|
3362
|
+
var DEFAULT_CAP = 15;
|
|
3363
|
+
function parseSitemapUrls(xml) {
|
|
3364
|
+
const out = [];
|
|
3365
|
+
const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
3366
|
+
let m;
|
|
3367
|
+
while ((m = re.exec(xml)) !== null) {
|
|
3368
|
+
const url = m[1];
|
|
3369
|
+
if (url) out.push(url.trim());
|
|
3370
|
+
}
|
|
3371
|
+
return out;
|
|
3372
|
+
}
|
|
3373
|
+
function parseHtmlLinks(html, baseUrl) {
|
|
3374
|
+
const out = /* @__PURE__ */ new Set();
|
|
3375
|
+
const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
3376
|
+
let m;
|
|
3377
|
+
while ((m = re.exec(html)) !== null) {
|
|
3378
|
+
const href = m[1];
|
|
3379
|
+
if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
3380
|
+
try {
|
|
3381
|
+
const u = new URL(href, baseUrl);
|
|
3382
|
+
if (u.origin !== new URL(baseUrl).origin) continue;
|
|
3383
|
+
out.add(u.pathname);
|
|
3384
|
+
} catch {
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
return [...out];
|
|
3388
|
+
}
|
|
3389
|
+
function family(pathname) {
|
|
3390
|
+
return pathname.split("/").filter(Boolean)[0] ?? "";
|
|
3391
|
+
}
|
|
3392
|
+
function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
|
|
3393
|
+
const seen = /* @__PURE__ */ new Set(["/"]);
|
|
3394
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
3395
|
+
for (const raw of urlsOrPaths) {
|
|
3396
|
+
let pathname;
|
|
3397
|
+
try {
|
|
3398
|
+
pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
|
|
3399
|
+
} catch {
|
|
3400
|
+
continue;
|
|
3401
|
+
}
|
|
3402
|
+
if (pathname === "/") continue;
|
|
3403
|
+
if (seen.has(pathname)) continue;
|
|
3404
|
+
seen.add(pathname);
|
|
3405
|
+
const fam = family(pathname);
|
|
3406
|
+
const arr = buckets.get(fam) ?? [];
|
|
3407
|
+
arr.push(pathname);
|
|
3408
|
+
buckets.set(fam, arr);
|
|
3409
|
+
}
|
|
3410
|
+
const result = ["/"];
|
|
3411
|
+
const families = [...buckets.values()];
|
|
3412
|
+
let guard = 0;
|
|
3413
|
+
while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
|
|
3414
|
+
for (const fam of families) {
|
|
3415
|
+
if (result.length >= cap) break;
|
|
3416
|
+
const next = fam.shift();
|
|
3417
|
+
if (next) result.push(next);
|
|
3418
|
+
}
|
|
3419
|
+
}
|
|
3420
|
+
return result;
|
|
3421
|
+
}
|
|
3422
|
+
function familyCountsOf(paths) {
|
|
3423
|
+
const counts = {};
|
|
3424
|
+
for (const p of paths) {
|
|
3425
|
+
const key = p === "/" ? "/" : `/${family(p)}`;
|
|
3426
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
3427
|
+
}
|
|
3428
|
+
return counts;
|
|
3429
|
+
}
|
|
3430
|
+
async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
|
|
3431
|
+
const origin = new URL(deployedUrl).origin;
|
|
3432
|
+
const abs = (paths) => paths.map((p) => new URL(p, origin).href);
|
|
3433
|
+
const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
|
|
3434
|
+
if (sitemapXml) {
|
|
3435
|
+
const urls = parseSitemapUrls(sitemapXml);
|
|
3436
|
+
if (urls.length > 0) {
|
|
3437
|
+
const paths = sampleRoutePaths(urls, cap);
|
|
3438
|
+
return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
const homeHtml = await deps.fetchText(origin);
|
|
3442
|
+
if (homeHtml) {
|
|
3443
|
+
const links = parseHtmlLinks(homeHtml, origin);
|
|
3444
|
+
if (links.length > 0) {
|
|
3445
|
+
const paths = sampleRoutePaths(links, cap);
|
|
3446
|
+
return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
// src/audits/browser.ts
|
|
3453
|
+
function isBroken(status) {
|
|
3454
|
+
return status === null || status >= 400;
|
|
3455
|
+
}
|
|
3456
|
+
function summarizeBrowser(routes, links, familyCounts) {
|
|
3457
|
+
const desktopChecks = routes.flatMap((r) => r.desktop);
|
|
3458
|
+
const mobileChecks = routes.flatMap((r) => r.mobile);
|
|
3459
|
+
const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
|
|
3460
|
+
const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
|
|
3461
|
+
const brokenLinks = links.filter((l) => isBroken(l.status)).length;
|
|
3462
|
+
const linksOk = links.length > 0 && brokenLinks === 0;
|
|
3463
|
+
const engines = [...new Set(desktopChecks.map((d) => d.engine))];
|
|
3464
|
+
const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
|
|
3465
|
+
const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
|
|
3466
|
+
const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
|
|
3467
|
+
return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
|
|
3468
|
+
}
|
|
3469
|
+
async function browserAudit(ctx) {
|
|
3470
|
+
const { site } = ctx;
|
|
3471
|
+
const label = siteLabel(site);
|
|
3472
|
+
if (!site.deployedUrl) {
|
|
3473
|
+
return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
|
|
3474
|
+
}
|
|
3475
|
+
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
3476
|
+
const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
|
|
3477
|
+
const runner = ctx.browserRunner ?? await defaultBrowserRunner();
|
|
3478
|
+
try {
|
|
3479
|
+
const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
|
|
3480
|
+
const routeResults = await runner.probe(discovered.routes);
|
|
3481
|
+
const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
|
|
3482
|
+
const linkResults = await runner.checkLinks(internalLinks);
|
|
3483
|
+
const summary = summarizeBrowser(
|
|
3484
|
+
routeResults,
|
|
3485
|
+
linkResults,
|
|
3486
|
+
discovered.familyCounts ?? familyCountsOf(discovered.routes)
|
|
3487
|
+
);
|
|
3488
|
+
const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
|
|
3489
|
+
return {
|
|
3490
|
+
audit: "browser",
|
|
3491
|
+
site: label,
|
|
3492
|
+
status,
|
|
3493
|
+
summary: summary.note,
|
|
3494
|
+
details: { ...summary, checkedAt: now.toISOString() }
|
|
3495
|
+
};
|
|
3496
|
+
} finally {
|
|
3497
|
+
await runner.close?.();
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
function defaultDiscoverDeps() {
|
|
3501
|
+
return {
|
|
3502
|
+
fetchText: async (url) => {
|
|
3503
|
+
try {
|
|
3504
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
3505
|
+
if (!res.ok) return null;
|
|
3506
|
+
return await res.text();
|
|
3507
|
+
} catch {
|
|
3508
|
+
return null;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
|
|
3514
|
+
var PAGE_TIMEOUT_MS = 3e4;
|
|
3515
|
+
async function defaultBrowserRunner() {
|
|
3516
|
+
const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
|
|
3517
|
+
const desktopEngines = [
|
|
3518
|
+
{ engine: "chromium", type: chromium },
|
|
3519
|
+
{ engine: "firefox", type: firefox },
|
|
3520
|
+
{ engine: "webkit", type: webkit }
|
|
3521
|
+
];
|
|
3522
|
+
const mobileTargets = [
|
|
3523
|
+
{ device: "Pixel 7", descriptor: devices2["Pixel 7"] },
|
|
3524
|
+
{ device: "iPhone 14", descriptor: devices2["iPhone 14"] }
|
|
3525
|
+
];
|
|
3526
|
+
return {
|
|
3527
|
+
async probe(urls) {
|
|
3528
|
+
const results = [];
|
|
3529
|
+
const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
|
|
3530
|
+
const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
|
|
3531
|
+
try {
|
|
3532
|
+
for (const url of urls) {
|
|
3533
|
+
const desktop = [];
|
|
3534
|
+
const linkSet = /* @__PURE__ */ new Set();
|
|
3535
|
+
for (let i = 0; i < desktopEngines.length; i++) {
|
|
3536
|
+
const engine = desktopEngines[i].engine;
|
|
3537
|
+
const browser = browsers[i];
|
|
3538
|
+
const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
|
3539
|
+
const page = await ctx.newPage();
|
|
3540
|
+
const errors = [];
|
|
3541
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
3542
|
+
let ok = false;
|
|
3543
|
+
try {
|
|
3544
|
+
const resp = await page.goto(url, {
|
|
3545
|
+
waitUntil: "domcontentloaded",
|
|
3546
|
+
timeout: PAGE_TIMEOUT_MS
|
|
3547
|
+
});
|
|
3548
|
+
const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
|
|
3549
|
+
ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
|
|
3550
|
+
if (engine === "chromium") {
|
|
3551
|
+
const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
|
|
3552
|
+
const origin = new URL(url).origin;
|
|
3553
|
+
for (const h of hrefs) {
|
|
3554
|
+
try {
|
|
3555
|
+
if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
|
|
3556
|
+
} catch {
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
} catch {
|
|
3561
|
+
ok = false;
|
|
3562
|
+
} finally {
|
|
3563
|
+
await ctx.close().catch(() => {
|
|
3564
|
+
});
|
|
3565
|
+
}
|
|
3566
|
+
desktop.push({ engine, ok });
|
|
3567
|
+
}
|
|
3568
|
+
const mobile = [];
|
|
3569
|
+
for (let i = 0; i < mobileTargets.length; i++) {
|
|
3570
|
+
const { device, descriptor } = mobileTargets[i];
|
|
3571
|
+
const browser = mobileBrowsers[i];
|
|
3572
|
+
const ctx = await browser.newContext({ ...descriptor });
|
|
3573
|
+
const page = await ctx.newPage();
|
|
3574
|
+
const errors = [];
|
|
3575
|
+
page.on("pageerror", (e) => errors.push(String(e)));
|
|
3576
|
+
let ok = false;
|
|
3577
|
+
try {
|
|
3578
|
+
const resp = await page.goto(url, {
|
|
3579
|
+
waitUntil: "domcontentloaded",
|
|
3580
|
+
timeout: PAGE_TIMEOUT_MS
|
|
3581
|
+
});
|
|
3582
|
+
const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
|
|
3583
|
+
ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
|
|
3584
|
+
} catch {
|
|
3585
|
+
ok = false;
|
|
3586
|
+
} finally {
|
|
3587
|
+
await ctx.close().catch(() => {
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
mobile.push({ device, ok });
|
|
3591
|
+
}
|
|
3592
|
+
results.push({ url, desktop, mobile, links: [...linkSet] });
|
|
3593
|
+
}
|
|
3594
|
+
} finally {
|
|
3595
|
+
await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
|
|
3596
|
+
})));
|
|
3597
|
+
}
|
|
3598
|
+
return results;
|
|
3599
|
+
},
|
|
3600
|
+
async checkLinks(urls) {
|
|
3601
|
+
const out = [];
|
|
3602
|
+
for (const url of urls) {
|
|
3603
|
+
let status;
|
|
3604
|
+
try {
|
|
3605
|
+
let res = await fetch(url, { method: "HEAD", redirect: "follow" });
|
|
3606
|
+
if (res.status === 405 || res.status === 501) {
|
|
3607
|
+
res = await fetch(url, { method: "GET", redirect: "follow" });
|
|
3608
|
+
}
|
|
3609
|
+
status = res.status;
|
|
3610
|
+
} catch {
|
|
3611
|
+
status = null;
|
|
3612
|
+
}
|
|
3613
|
+
out.push({ url, status });
|
|
3614
|
+
}
|
|
3615
|
+
return out;
|
|
3616
|
+
}
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3076
3620
|
// src/audits/index.ts
|
|
3077
3621
|
var REGISTRY = {
|
|
3078
3622
|
deps: depsAudit,
|
|
3079
3623
|
lint: lintAudit,
|
|
3080
3624
|
security: securityAudit,
|
|
3081
3625
|
lighthouse: lighthouseAudit,
|
|
3082
|
-
a11y: a11yAudit
|
|
3626
|
+
a11y: a11yAudit,
|
|
3627
|
+
domain: domainAudit,
|
|
3628
|
+
browser: browserAudit
|
|
3083
3629
|
};
|
|
3084
3630
|
var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
|
|
3085
3631
|
var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
|
|
@@ -3556,8 +4102,13 @@ function deployedUrlNotice(which, url, cwd) {
|
|
|
3556
4102
|
if (others.length === 0) return null;
|
|
3557
4103
|
return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
|
|
3558
4104
|
}
|
|
4105
|
+
var CHECKOUT_FREE_AUDITS = /* @__PURE__ */ new Set([
|
|
4106
|
+
"lighthouse",
|
|
4107
|
+
"domain",
|
|
4108
|
+
"browser"
|
|
4109
|
+
]);
|
|
3559
4110
|
function auditNeedsCheckout(site, which) {
|
|
3560
|
-
const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n
|
|
4111
|
+
const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => CHECKOUT_FREE_AUDITS.has(n));
|
|
3561
4112
|
return !deployedCapable;
|
|
3562
4113
|
}
|
|
3563
4114
|
function applyDeployedUrl(sites, url) {
|
|
@@ -5811,6 +6362,120 @@ async function queueDraft(base, report) {
|
|
|
5811
6362
|
return { queued: true, supersededIds };
|
|
5812
6363
|
}
|
|
5813
6364
|
|
|
6365
|
+
// src/reports/auto-tick.ts
|
|
6366
|
+
init_checklist();
|
|
6367
|
+
init_url();
|
|
6368
|
+
var STALE_DAYS = 3;
|
|
6369
|
+
var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
|
|
6370
|
+
function isFresh(checkedAt, now) {
|
|
6371
|
+
if (!checkedAt) return false;
|
|
6372
|
+
const t = new Date(checkedAt).getTime();
|
|
6373
|
+
if (Number.isNaN(t)) return false;
|
|
6374
|
+
return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
|
|
6375
|
+
}
|
|
6376
|
+
var CERT_MIN_DAYS = 14;
|
|
6377
|
+
function autoTickChecklist(site, reportType, now, signals) {
|
|
6378
|
+
const out = /* @__PURE__ */ new Map();
|
|
6379
|
+
const fields = new Set(checklistFor(reportType).map((i) => i.field));
|
|
6380
|
+
if (fields.has("Maint: Google Indexed")) {
|
|
6381
|
+
const g = googleEvidence(now, signals.search);
|
|
6382
|
+
if (g) out.set("Maint: Google Indexed", g);
|
|
6383
|
+
}
|
|
6384
|
+
if (fields.has("Maint: Security Updates")) {
|
|
6385
|
+
const s = securityEvidence(site, now);
|
|
6386
|
+
if (s) out.set("Maint: Security Updates", s);
|
|
6387
|
+
}
|
|
6388
|
+
if (fields.has("Maint: Domain, DNS & SSL")) {
|
|
6389
|
+
const d = domainEvidence(site, now);
|
|
6390
|
+
if (d) out.set("Maint: Domain, DNS & SSL", d);
|
|
6391
|
+
}
|
|
6392
|
+
if (fields.has("Test: Desktop Browsers")) {
|
|
6393
|
+
const e = browserEvidence(
|
|
6394
|
+
site.crossbrowserOk,
|
|
6395
|
+
site,
|
|
6396
|
+
now,
|
|
6397
|
+
"Desktop renders cleanly",
|
|
6398
|
+
"render errors"
|
|
6399
|
+
);
|
|
6400
|
+
if (e) out.set("Test: Desktop Browsers", e);
|
|
6401
|
+
}
|
|
6402
|
+
if (fields.has("Test: Mobile Browsers")) {
|
|
6403
|
+
const e = browserEvidence(
|
|
6404
|
+
site.mobileOk,
|
|
6405
|
+
site,
|
|
6406
|
+
now,
|
|
6407
|
+
"Mobile renders cleanly",
|
|
6408
|
+
"overflow/errors"
|
|
6409
|
+
);
|
|
6410
|
+
if (e) out.set("Test: Mobile Browsers", e);
|
|
6411
|
+
}
|
|
6412
|
+
if (fields.has("Test: Links & Navigation")) {
|
|
6413
|
+
const broken = site.brokenLinks;
|
|
6414
|
+
const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
|
|
6415
|
+
const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
|
|
6416
|
+
if (e) out.set("Test: Links & Navigation", e);
|
|
6417
|
+
}
|
|
6418
|
+
return out;
|
|
6419
|
+
}
|
|
6420
|
+
function browserEvidence(ok, site, now, passNote, failNote) {
|
|
6421
|
+
if (ok === null || !site.browserCheckedAt) return null;
|
|
6422
|
+
const at = site.browserCheckedAt;
|
|
6423
|
+
if (!isFresh(at, now)) {
|
|
6424
|
+
return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
|
|
6425
|
+
}
|
|
6426
|
+
return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
|
|
6427
|
+
}
|
|
6428
|
+
function securityEvidence(site, now) {
|
|
6429
|
+
const crit = site.securityVulnsCritical;
|
|
6430
|
+
const high = site.securityVulnsHigh;
|
|
6431
|
+
if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
|
|
6432
|
+
const at = site.lastSecurityAuditAt;
|
|
6433
|
+
if (!isFresh(at, now)) {
|
|
6434
|
+
return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
|
|
6435
|
+
}
|
|
6436
|
+
if (crit === 0 && high === 0) {
|
|
6437
|
+
return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
|
|
6438
|
+
}
|
|
6439
|
+
return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
|
|
6440
|
+
}
|
|
6441
|
+
function googleEvidence(now, search) {
|
|
6442
|
+
const at = now.toISOString();
|
|
6443
|
+
if (search.softFailed) {
|
|
6444
|
+
return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
|
|
6445
|
+
}
|
|
6446
|
+
if (search.value === null) return null;
|
|
6447
|
+
if (search.value.foundOnPage1) {
|
|
6448
|
+
const pos2 = search.value.position;
|
|
6449
|
+
return {
|
|
6450
|
+
result: "pass",
|
|
6451
|
+
checkedAt: at,
|
|
6452
|
+
note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
|
|
6453
|
+
};
|
|
6454
|
+
}
|
|
6455
|
+
const pos = search.value.position;
|
|
6456
|
+
return {
|
|
6457
|
+
result: "fail",
|
|
6458
|
+
checkedAt: at,
|
|
6459
|
+
note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
|
|
6460
|
+
};
|
|
6461
|
+
}
|
|
6462
|
+
function domainEvidence(site, now) {
|
|
6463
|
+
if (!site.url || isNetlifyAppUrl(site.url)) return null;
|
|
6464
|
+
if (!site.domainCheckedAt) return null;
|
|
6465
|
+
const at = site.domainCheckedAt;
|
|
6466
|
+
if (!isFresh(site.domainCheckedAt, now)) {
|
|
6467
|
+
return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
|
|
6468
|
+
}
|
|
6469
|
+
const days = site.certDaysRemaining;
|
|
6470
|
+
if (days === null) {
|
|
6471
|
+
return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
|
|
6472
|
+
}
|
|
6473
|
+
if (days <= CERT_MIN_DAYS) {
|
|
6474
|
+
return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
|
|
6475
|
+
}
|
|
6476
|
+
return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
|
|
6477
|
+
}
|
|
6478
|
+
|
|
5814
6479
|
// src/reports/draft.ts
|
|
5815
6480
|
init_attachments();
|
|
5816
6481
|
|
|
@@ -5829,7 +6494,7 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
5829
6494
|
import { JWT } from "google-auth-library";
|
|
5830
6495
|
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
|
5831
6496
|
var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
|
|
5832
|
-
var
|
|
6497
|
+
var MS_PER_DAY3 = 864e5;
|
|
5833
6498
|
function ymd2(d) {
|
|
5834
6499
|
return d.toISOString().slice(0, 10);
|
|
5835
6500
|
}
|
|
@@ -5842,9 +6507,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
|
|
|
5842
6507
|
subject: query.subject
|
|
5843
6508
|
});
|
|
5844
6509
|
const client = new BetaAnalyticsDataClient({ authClient });
|
|
5845
|
-
const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) /
|
|
5846
|
-
const prevEnd = new Date(periodStart.getTime() -
|
|
5847
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays *
|
|
6510
|
+
const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
|
|
6511
|
+
const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
|
|
6512
|
+
const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
|
|
5848
6513
|
const property = `properties/${query.propertyId}`;
|
|
5849
6514
|
const run = async (start, end) => {
|
|
5850
6515
|
const [resp] = await client.runReport({
|
|
@@ -5968,7 +6633,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
5968
6633
|
const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
|
|
5969
6634
|
const periodEnd = today;
|
|
5970
6635
|
const completedOn = today;
|
|
5971
|
-
const lastTestedDate = reportType === "Maintenance" && siteRow.
|
|
6636
|
+
const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
|
|
5972
6637
|
const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
5973
6638
|
const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
5974
6639
|
const gaUsers = gaResult.value;
|
|
@@ -6015,6 +6680,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
6015
6680
|
supersededIds: outcome2.supersededIds
|
|
6016
6681
|
};
|
|
6017
6682
|
}
|
|
6683
|
+
const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
|
|
6684
|
+
const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
|
|
6685
|
+
const autoEvidence = Object.fromEntries(evidence);
|
|
6018
6686
|
const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
|
|
6019
6687
|
const created = await createDraft(base, {
|
|
6020
6688
|
reportId,
|
|
@@ -6028,7 +6696,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
|
6028
6696
|
lastTestedDate,
|
|
6029
6697
|
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
6030
6698
|
...search ? { searchFoundPage1: search.foundOnPage1 } : {},
|
|
6031
|
-
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
|
|
6699
|
+
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
|
|
6700
|
+
checklistTicks,
|
|
6701
|
+
autoEvidence
|
|
6032
6702
|
});
|
|
6033
6703
|
await uploadDraftHtml(created.id, slug, periodEnd, html);
|
|
6034
6704
|
const outcome = await queueDraft(base, {
|
|
@@ -6859,7 +7529,9 @@ var AUDIT_DESCRIPTIONS = {
|
|
|
6859
7529
|
lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
|
|
6860
7530
|
a11y: "Playwright + axe against the canonical a11y routes.",
|
|
6861
7531
|
security: "pnpm audit (falls back to npm audit), prod-deps by default.",
|
|
6862
|
-
lint: "ESLint + Prettier using the canonical configs."
|
|
7532
|
+
lint: "ESLint + Prettier using the canonical configs.",
|
|
7533
|
+
domain: "DNS resolve + TLS cert expiry against the deployed URL (checkout-free).",
|
|
7534
|
+
browser: "Playwright across desktop engines + mobile devices + link-check against the deployed URL (checkout-free)."
|
|
6863
7535
|
};
|
|
6864
7536
|
var RECIPE_DESCRIPTIONS = {
|
|
6865
7537
|
"sync-configs": "Overwrite a site's canonical configs to match @reddoorla/maintenance.",
|