@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 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
- return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
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 * MS_PER_DAY2;
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, MS_PER_DAY2, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
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
- MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
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 === "lighthouse");
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 MS_PER_DAY = 864e5;
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()) / MS_PER_DAY);
5846
- const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);
5847
- const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);
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.testingDay ? new Date(siteRow.testingDay) : null;
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.",