@reddoorla/maintenance 0.49.0 → 0.50.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/index.js CHANGED
@@ -939,13 +939,348 @@ async function a11yAudit(ctx) {
939
939
  }
940
940
  }
941
941
 
942
+ // src/audits/domain.ts
943
+ import { promises as dnsPromises } from "dns";
944
+ import tls from "tls";
945
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
946
+ async function checkDomain(url, deps) {
947
+ let host;
948
+ try {
949
+ host = new URL(url).hostname;
950
+ } catch {
951
+ return { resolved: false, certDaysRemaining: null };
952
+ }
953
+ try {
954
+ await deps.lookup(host);
955
+ } catch {
956
+ return { resolved: false, certDaysRemaining: null };
957
+ }
958
+ let validTo;
959
+ try {
960
+ validTo = await deps.certValidTo(host);
961
+ } catch {
962
+ validTo = null;
963
+ }
964
+ if (!validTo || Number.isNaN(validTo.getTime()))
965
+ return { resolved: true, certDaysRemaining: null };
966
+ return {
967
+ resolved: true,
968
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
969
+ };
970
+ }
971
+ function defaultDomainDeps(now) {
972
+ return {
973
+ lookup: async (host) => {
974
+ await dnsPromises.lookup(host);
975
+ },
976
+ certValidTo: (host) => new Promise((resolvePromise) => {
977
+ const socket = tls.connect(
978
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
979
+ () => {
980
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
981
+ socket.end();
982
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
983
+ resolvePromise(validTo);
984
+ }
985
+ );
986
+ socket.on("error", () => resolvePromise(null));
987
+ socket.on("timeout", () => {
988
+ socket.destroy();
989
+ resolvePromise(null);
990
+ });
991
+ }),
992
+ now
993
+ };
994
+ }
995
+ async function domainAudit(ctx) {
996
+ const { site } = ctx;
997
+ const label = siteLabel(site);
998
+ if (!site.deployedUrl) {
999
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
1000
+ }
1001
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1002
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
1003
+ const check = await checkDomain(site.deployedUrl, deps);
1004
+ const checkedAt = now.toISOString();
1005
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
1006
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
1007
+ return {
1008
+ audit: "domain",
1009
+ site: label,
1010
+ status,
1011
+ summary,
1012
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
1013
+ };
1014
+ }
1015
+
1016
+ // src/audits/route-discovery.ts
1017
+ var DEFAULT_CAP = 15;
1018
+ function parseSitemapUrls(xml) {
1019
+ const out = [];
1020
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
1021
+ let m;
1022
+ while ((m = re.exec(xml)) !== null) {
1023
+ const url = m[1];
1024
+ if (url) out.push(url.trim());
1025
+ }
1026
+ return out;
1027
+ }
1028
+ function parseHtmlLinks(html, baseUrl) {
1029
+ const out = /* @__PURE__ */ new Set();
1030
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
1031
+ let m;
1032
+ while ((m = re.exec(html)) !== null) {
1033
+ const href = m[1];
1034
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
1035
+ try {
1036
+ const u = new URL(href, baseUrl);
1037
+ if (u.origin !== new URL(baseUrl).origin) continue;
1038
+ out.add(u.pathname);
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ return [...out];
1043
+ }
1044
+ function family(pathname) {
1045
+ return pathname.split("/").filter(Boolean)[0] ?? "";
1046
+ }
1047
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
1048
+ const seen = /* @__PURE__ */ new Set(["/"]);
1049
+ const buckets = /* @__PURE__ */ new Map();
1050
+ for (const raw of urlsOrPaths) {
1051
+ let pathname;
1052
+ try {
1053
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
1054
+ } catch {
1055
+ continue;
1056
+ }
1057
+ if (pathname === "/") continue;
1058
+ if (seen.has(pathname)) continue;
1059
+ seen.add(pathname);
1060
+ const fam = family(pathname);
1061
+ const arr = buckets.get(fam) ?? [];
1062
+ arr.push(pathname);
1063
+ buckets.set(fam, arr);
1064
+ }
1065
+ const result = ["/"];
1066
+ const families = [...buckets.values()];
1067
+ let guard = 0;
1068
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
1069
+ for (const fam of families) {
1070
+ if (result.length >= cap) break;
1071
+ const next = fam.shift();
1072
+ if (next) result.push(next);
1073
+ }
1074
+ }
1075
+ return result;
1076
+ }
1077
+ function familyCountsOf(paths) {
1078
+ const counts = {};
1079
+ for (const p of paths) {
1080
+ const key = p === "/" ? "/" : `/${family(p)}`;
1081
+ counts[key] = (counts[key] ?? 0) + 1;
1082
+ }
1083
+ return counts;
1084
+ }
1085
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
1086
+ const origin = new URL(deployedUrl).origin;
1087
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
1088
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
1089
+ if (sitemapXml) {
1090
+ const urls = parseSitemapUrls(sitemapXml);
1091
+ if (urls.length > 0) {
1092
+ const paths = sampleRoutePaths(urls, cap);
1093
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
1094
+ }
1095
+ }
1096
+ const homeHtml = await deps.fetchText(origin);
1097
+ if (homeHtml) {
1098
+ const links = parseHtmlLinks(homeHtml, origin);
1099
+ if (links.length > 0) {
1100
+ const paths = sampleRoutePaths(links, cap);
1101
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
1102
+ }
1103
+ }
1104
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
1105
+ }
1106
+
1107
+ // src/audits/browser.ts
1108
+ function isBroken(status) {
1109
+ return status === null || status >= 400;
1110
+ }
1111
+ function summarizeBrowser(routes, links, familyCounts) {
1112
+ const desktopChecks = routes.flatMap((r) => r.desktop);
1113
+ const mobileChecks = routes.flatMap((r) => r.mobile);
1114
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
1115
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
1116
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
1117
+ const linksOk = links.length > 0 && brokenLinks === 0;
1118
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
1119
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
1120
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
1121
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
1122
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
1123
+ }
1124
+ async function browserAudit(ctx) {
1125
+ const { site } = ctx;
1126
+ const label = siteLabel(site);
1127
+ if (!site.deployedUrl) {
1128
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
1129
+ }
1130
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1131
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
1132
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
1133
+ try {
1134
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
1135
+ const routeResults = await runner.probe(discovered.routes);
1136
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
1137
+ const linkResults = await runner.checkLinks(internalLinks);
1138
+ const summary = summarizeBrowser(
1139
+ routeResults,
1140
+ linkResults,
1141
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
1142
+ );
1143
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
1144
+ return {
1145
+ audit: "browser",
1146
+ site: label,
1147
+ status,
1148
+ summary: summary.note,
1149
+ details: { ...summary, checkedAt: now.toISOString() }
1150
+ };
1151
+ } finally {
1152
+ await runner.close?.();
1153
+ }
1154
+ }
1155
+ function defaultDiscoverDeps() {
1156
+ return {
1157
+ fetchText: async (url) => {
1158
+ try {
1159
+ const res = await fetch(url, { redirect: "follow" });
1160
+ if (!res.ok) return null;
1161
+ return await res.text();
1162
+ } catch {
1163
+ return null;
1164
+ }
1165
+ }
1166
+ };
1167
+ }
1168
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
1169
+ var PAGE_TIMEOUT_MS = 3e4;
1170
+ async function defaultBrowserRunner() {
1171
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
1172
+ const desktopEngines = [
1173
+ { engine: "chromium", type: chromium },
1174
+ { engine: "firefox", type: firefox },
1175
+ { engine: "webkit", type: webkit }
1176
+ ];
1177
+ const mobileTargets = [
1178
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
1179
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
1180
+ ];
1181
+ return {
1182
+ async probe(urls) {
1183
+ const results = [];
1184
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
1185
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
1186
+ try {
1187
+ for (const url of urls) {
1188
+ const desktop = [];
1189
+ const linkSet = /* @__PURE__ */ new Set();
1190
+ for (let i = 0; i < desktopEngines.length; i++) {
1191
+ const engine = desktopEngines[i].engine;
1192
+ const browser = browsers[i];
1193
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
1194
+ const page = await ctx.newPage();
1195
+ const errors = [];
1196
+ page.on("pageerror", (e) => errors.push(String(e)));
1197
+ let ok = false;
1198
+ try {
1199
+ const resp = await page.goto(url, {
1200
+ waitUntil: "domcontentloaded",
1201
+ timeout: PAGE_TIMEOUT_MS
1202
+ });
1203
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
1204
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
1205
+ if (engine === "chromium") {
1206
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
1207
+ const origin = new URL(url).origin;
1208
+ for (const h of hrefs) {
1209
+ try {
1210
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
1211
+ } catch {
1212
+ }
1213
+ }
1214
+ }
1215
+ } catch {
1216
+ ok = false;
1217
+ } finally {
1218
+ await ctx.close().catch(() => {
1219
+ });
1220
+ }
1221
+ desktop.push({ engine, ok });
1222
+ }
1223
+ const mobile = [];
1224
+ for (let i = 0; i < mobileTargets.length; i++) {
1225
+ const { device, descriptor } = mobileTargets[i];
1226
+ const browser = mobileBrowsers[i];
1227
+ const ctx = await browser.newContext({ ...descriptor });
1228
+ const page = await ctx.newPage();
1229
+ const errors = [];
1230
+ page.on("pageerror", (e) => errors.push(String(e)));
1231
+ let ok = false;
1232
+ try {
1233
+ const resp = await page.goto(url, {
1234
+ waitUntil: "domcontentloaded",
1235
+ timeout: PAGE_TIMEOUT_MS
1236
+ });
1237
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
1238
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
1239
+ } catch {
1240
+ ok = false;
1241
+ } finally {
1242
+ await ctx.close().catch(() => {
1243
+ });
1244
+ }
1245
+ mobile.push({ device, ok });
1246
+ }
1247
+ results.push({ url, desktop, mobile, links: [...linkSet] });
1248
+ }
1249
+ } finally {
1250
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
1251
+ })));
1252
+ }
1253
+ return results;
1254
+ },
1255
+ async checkLinks(urls) {
1256
+ const out = [];
1257
+ for (const url of urls) {
1258
+ let status;
1259
+ try {
1260
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
1261
+ if (res.status === 405 || res.status === 501) {
1262
+ res = await fetch(url, { method: "GET", redirect: "follow" });
1263
+ }
1264
+ status = res.status;
1265
+ } catch {
1266
+ status = null;
1267
+ }
1268
+ out.push({ url, status });
1269
+ }
1270
+ return out;
1271
+ }
1272
+ };
1273
+ }
1274
+
942
1275
  // src/audits/index.ts
943
1276
  var REGISTRY = {
944
1277
  deps: depsAudit,
945
1278
  lint: lintAudit,
946
1279
  security: securityAudit,
947
1280
  lighthouse: lighthouseAudit,
948
- a11y: a11yAudit
1281
+ a11y: a11yAudit,
1282
+ domain: domainAudit,
1283
+ browser: browserAudit
949
1284
  };
950
1285
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
951
1286
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -2557,6 +2892,16 @@ function isHttpUrl(s) {
2557
2892
  }
2558
2893
  return parsed.protocol === "http:" || parsed.protocol === "https:";
2559
2894
  }
2895
+ function isNetlifyAppUrl(s) {
2896
+ let parsed;
2897
+ try {
2898
+ parsed = new URL(s);
2899
+ } catch {
2900
+ return false;
2901
+ }
2902
+ const host = parsed.hostname.toLowerCase();
2903
+ return host === "netlify.app" || host.endsWith(".netlify.app");
2904
+ }
2560
2905
 
2561
2906
  // src/inventory/json.ts
2562
2907
  function validate(raw) {
@@ -2684,6 +3029,14 @@ function mapRow(rec) {
2684
3029
  securityVulnsHigh: f["Security Vulns High"] ?? null,
2685
3030
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
2686
3031
  securityVulnsLow: f["Security Vulns Low"] ?? null,
3032
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
3033
+ certDaysRemaining: f["Cert days remaining"] ?? null,
3034
+ domainCheckedAt: f["Domain checked at"] ?? null,
3035
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
3036
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
3037
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
3038
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
3039
+ browserCheckedAt: f["Browser checked at"] ?? null,
2687
3040
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
2688
3041
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
2689
3042
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -3363,9 +3716,32 @@ function mapRow2(rec) {
3363
3716
  deliveryStatus: f["Delivery status"] ?? "pending",
3364
3717
  renderedHtmlAttachment: html,
3365
3718
  resendMessageId: f["Resend message ID"] ?? null,
3366
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
3719
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
3720
+ autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
3367
3721
  };
3368
3722
  }
3723
+ function parseAutoEvidence(raw) {
3724
+ if (typeof raw !== "string" || !raw.trim()) return null;
3725
+ let parsed;
3726
+ try {
3727
+ parsed = JSON.parse(raw);
3728
+ } catch {
3729
+ return null;
3730
+ }
3731
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3732
+ const out = {};
3733
+ for (const [field, v] of Object.entries(parsed)) {
3734
+ if (!v || typeof v !== "object") continue;
3735
+ const o = v;
3736
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
3737
+ out[field] = {
3738
+ result: o.result,
3739
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
3740
+ note: typeof o.note === "string" ? o.note : ""
3741
+ };
3742
+ }
3743
+ return Object.keys(out).length > 0 ? out : null;
3744
+ }
3369
3745
  function lighthouseFromFields(f) {
3370
3746
  const p = f["Lighthouse \u2014 Performance"];
3371
3747
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -3399,6 +3775,10 @@ async function createDraft(base, input) {
3399
3775
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
3400
3776
  if (input.period !== void 0) fields["Period"] = input.period;
3401
3777
  if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
3778
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
3779
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
3780
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
3781
+ }
3402
3782
  const created = await base(REPORTS_TABLE).create([{ fields }]);
3403
3783
  const rec = created[0];
3404
3784
  if (!rec) throw new Error("Airtable create returned no records");
@@ -3467,6 +3847,118 @@ async function queueDraft(base, report) {
3467
3847
  return { queued: true, supersededIds };
3468
3848
  }
3469
3849
 
3850
+ // src/reports/auto-tick.ts
3851
+ var STALE_DAYS = 3;
3852
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
3853
+ function isFresh(checkedAt, now) {
3854
+ if (!checkedAt) return false;
3855
+ const t = new Date(checkedAt).getTime();
3856
+ if (Number.isNaN(t)) return false;
3857
+ return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
3858
+ }
3859
+ var CERT_MIN_DAYS = 14;
3860
+ function autoTickChecklist(site, reportType, now, signals) {
3861
+ const out = /* @__PURE__ */ new Map();
3862
+ const fields = new Set(checklistFor(reportType).map((i) => i.field));
3863
+ if (fields.has("Maint: Google Indexed")) {
3864
+ const g = googleEvidence(now, signals.search);
3865
+ if (g) out.set("Maint: Google Indexed", g);
3866
+ }
3867
+ if (fields.has("Maint: Security Updates")) {
3868
+ const s = securityEvidence(site, now);
3869
+ if (s) out.set("Maint: Security Updates", s);
3870
+ }
3871
+ if (fields.has("Maint: Domain, DNS & SSL")) {
3872
+ const d = domainEvidence(site, now);
3873
+ if (d) out.set("Maint: Domain, DNS & SSL", d);
3874
+ }
3875
+ if (fields.has("Test: Desktop Browsers")) {
3876
+ const e = browserEvidence(
3877
+ site.crossbrowserOk,
3878
+ site,
3879
+ now,
3880
+ "Desktop renders cleanly",
3881
+ "render errors"
3882
+ );
3883
+ if (e) out.set("Test: Desktop Browsers", e);
3884
+ }
3885
+ if (fields.has("Test: Mobile Browsers")) {
3886
+ const e = browserEvidence(
3887
+ site.mobileOk,
3888
+ site,
3889
+ now,
3890
+ "Mobile renders cleanly",
3891
+ "overflow/errors"
3892
+ );
3893
+ if (e) out.set("Test: Mobile Browsers", e);
3894
+ }
3895
+ if (fields.has("Test: Links & Navigation")) {
3896
+ const broken = site.brokenLinks;
3897
+ const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
3898
+ const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
3899
+ if (e) out.set("Test: Links & Navigation", e);
3900
+ }
3901
+ return out;
3902
+ }
3903
+ function browserEvidence(ok, site, now, passNote, failNote) {
3904
+ if (ok === null || !site.browserCheckedAt) return null;
3905
+ const at = site.browserCheckedAt;
3906
+ if (!isFresh(at, now)) {
3907
+ return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
3908
+ }
3909
+ return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
3910
+ }
3911
+ function securityEvidence(site, now) {
3912
+ const crit = site.securityVulnsCritical;
3913
+ const high = site.securityVulnsHigh;
3914
+ if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
3915
+ const at = site.lastSecurityAuditAt;
3916
+ if (!isFresh(at, now)) {
3917
+ return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
3918
+ }
3919
+ if (crit === 0 && high === 0) {
3920
+ return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
3921
+ }
3922
+ return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
3923
+ }
3924
+ function googleEvidence(now, search) {
3925
+ const at = now.toISOString();
3926
+ if (search.softFailed) {
3927
+ return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
3928
+ }
3929
+ if (search.value === null) return null;
3930
+ if (search.value.foundOnPage1) {
3931
+ const pos2 = search.value.position;
3932
+ return {
3933
+ result: "pass",
3934
+ checkedAt: at,
3935
+ note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
3936
+ };
3937
+ }
3938
+ const pos = search.value.position;
3939
+ return {
3940
+ result: "fail",
3941
+ checkedAt: at,
3942
+ note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
3943
+ };
3944
+ }
3945
+ function domainEvidence(site, now) {
3946
+ if (!site.url || isNetlifyAppUrl(site.url)) return null;
3947
+ if (!site.domainCheckedAt) return null;
3948
+ const at = site.domainCheckedAt;
3949
+ if (!isFresh(site.domainCheckedAt, now)) {
3950
+ return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
3951
+ }
3952
+ const days = site.certDaysRemaining;
3953
+ if (days === null) {
3954
+ return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
3955
+ }
3956
+ if (days <= CERT_MIN_DAYS) {
3957
+ return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
3958
+ }
3959
+ return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
3960
+ }
3961
+
3470
3962
  // src/reports/airtable/attachments.ts
3471
3963
  function looksLikeHtml(bytes) {
3472
3964
  const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
@@ -3538,7 +4030,7 @@ import { readFileSync as readFileSync3 } from "fs";
3538
4030
  import { JWT } from "google-auth-library";
3539
4031
  import { BetaAnalyticsDataClient } from "@google-analytics/data";
3540
4032
  var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
3541
- var MS_PER_DAY = 864e5;
4033
+ var MS_PER_DAY3 = 864e5;
3542
4034
  function ymd2(d) {
3543
4035
  return d.toISOString().slice(0, 10);
3544
4036
  }
@@ -3551,9 +4043,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
3551
4043
  subject: query.subject
3552
4044
  });
3553
4045
  const client = new BetaAnalyticsDataClient({ authClient });
3554
- const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);
3555
- const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);
3556
- const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);
4046
+ const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
4047
+ const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
4048
+ const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
3557
4049
  const property = `properties/${query.propertyId}`;
3558
4050
  const run = async (start, end) => {
3559
4051
  const [resp] = await client.runReport({
@@ -3677,7 +4169,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3677
4169
  const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
3678
4170
  const periodEnd = today;
3679
4171
  const completedOn = today;
3680
- const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
4172
+ const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
3681
4173
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3682
4174
  const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3683
4175
  const gaUsers = gaResult.value;
@@ -3724,6 +4216,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3724
4216
  supersededIds: outcome2.supersededIds
3725
4217
  };
3726
4218
  }
4219
+ const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
4220
+ const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
4221
+ const autoEvidence = Object.fromEntries(evidence);
3727
4222
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
3728
4223
  const created = await createDraft(base, {
3729
4224
  reportId,
@@ -3737,7 +4232,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3737
4232
  lastTestedDate,
3738
4233
  ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
3739
4234
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
3740
- ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
4235
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
4236
+ checklistTicks,
4237
+ autoEvidence
3741
4238
  });
3742
4239
  await uploadDraftHtml(created.id, slug, periodEnd, html);
3743
4240
  const outcome = await queueDraft(base, {
@@ -4026,35 +4523,33 @@ async function sendOne(client, base, site, report) {
4026
4523
  });
4027
4524
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
4028
4525
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
4526
+ const attachments = [
4527
+ toInlineAttachment({
4528
+ bytes: header.bytes,
4529
+ filename: `${cidName}.jpg`,
4530
+ contentType: header.contentType,
4531
+ cid: cidName
4532
+ })
4533
+ ];
4534
+ for (const img of [bundled.check, bundled.blurred]) {
4535
+ if (html.includes(`cid:${img.cid}`)) {
4536
+ attachments.push(
4537
+ toInlineAttachment({
4538
+ bytes: img.bytes,
4539
+ filename: img.filename,
4540
+ contentType: img.contentType,
4541
+ cid: img.cid
4542
+ })
4543
+ );
4544
+ }
4545
+ }
4029
4546
  const payload = {
4030
4547
  from: FROM_ADDRESS,
4031
4548
  to,
4032
4549
  replyTo: REPLY_TO,
4033
4550
  subject,
4034
4551
  html,
4035
- attachments: [
4036
- toInlineAttachment({
4037
- bytes: header.bytes,
4038
- filename: `${cidName}.jpg`,
4039
- contentType: header.contentType,
4040
- cid: cidName
4041
- }),
4042
- // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
4043
- // in the template. Attached inline so the email is self-contained — no
4044
- // external CDN dependency, no image-blocked broken icons in webmail.
4045
- toInlineAttachment({
4046
- bytes: bundled.check.bytes,
4047
- filename: bundled.check.filename,
4048
- contentType: bundled.check.contentType,
4049
- cid: bundled.check.cid
4050
- }),
4051
- toInlineAttachment({
4052
- bytes: bundled.blurred.bytes,
4053
- filename: bundled.blurred.filename,
4054
- contentType: bundled.blurred.contentType,
4055
- cid: bundled.blurred.cid
4056
- })
4057
- ],
4552
+ attachments,
4058
4553
  // Stable across retries of the same row — if Airtable stamping fails after a
4059
4554
  // successful Resend, the next --send-ready replays with the same key and
4060
4555
  // Resend returns the original message id rather than sending a duplicate.
@@ -4251,7 +4746,9 @@ function checklistBlock(r) {
4251
4746
  const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
4252
4747
  const boxes = items.map((item) => {
4253
4748
  const checked = r.checklist[item.field] === true ? " checked" : "";
4254
- return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}</label>`;
4749
+ const ev = r.autoEvidence?.[item.field];
4750
+ const badge = ev ? ev.result === "pass" ? ` <span class="auto-badge auto-pass" title="${escapeHtml(ev.note)}">auto \u2713</span>` : ` <span class="auto-badge auto-amber" title="${escapeHtml(ev.note)}">auto: ${escapeHtml(ev.note)}</span>` : "";
4751
+ return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;
4255
4752
  }).join("");
4256
4753
  return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
4257
4754
  }
@@ -4378,6 +4875,9 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
4378
4875
  .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
4379
4876
  .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
4380
4877
  .check-item input { margin: 0; }
4878
+ .auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }
4879
+ .auto-pass { background: #e6f4ea; color: #137333; }
4880
+ .auto-amber { background: #fef7e0; color: #b06000; }
4381
4881
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4382
4882
  .subm-list { list-style: none; padding: 0; margin: 0; }
4383
4883
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }