@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/cli/bin.js +624 -48
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -5
- package/dist/cli/commands/audit.js +434 -14
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +84 -2
- package/dist/index.js +532 -32
- 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/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
|
|
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()) /
|
|
3555
|
-
const prevEnd = new Date(periodStart.getTime() -
|
|
3556
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays *
|
|
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.
|
|
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
|
-
|
|
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; }
|