@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/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,15 @@ 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
+ securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
3034
+ certDaysRemaining: f["Cert days remaining"] ?? null,
3035
+ domainCheckedAt: f["Domain checked at"] ?? null,
3036
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
3037
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
3038
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
3039
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
3040
+ browserCheckedAt: f["Browser checked at"] ?? null,
2687
3041
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
2688
3042
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
2689
3043
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -2706,6 +3060,40 @@ async function listWebsites(base) {
2706
3060
  });
2707
3061
  return out;
2708
3062
  }
3063
+ var SEVERITY_RANK = {
3064
+ critical: 0,
3065
+ high: 1,
3066
+ moderate: 2,
3067
+ low: 3
3068
+ };
3069
+ function normalizeSecurityAdvisory(raw) {
3070
+ if (!raw || typeof raw !== "object") return null;
3071
+ const e = raw;
3072
+ const module = typeof e["module"] === "string" ? e["module"] : null;
3073
+ const severity = e["severity"];
3074
+ if (module === null) return null;
3075
+ if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
3076
+ return null;
3077
+ const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
3078
+ return {
3079
+ module,
3080
+ severity,
3081
+ title: typeof e["title"] === "string" ? e["title"] : "",
3082
+ cves,
3083
+ url: typeof e["url"] === "string" ? e["url"] : null
3084
+ };
3085
+ }
3086
+ function parseSecurityAdvisories(raw) {
3087
+ if (typeof raw !== "string" || raw.trim() === "") return null;
3088
+ let parsed;
3089
+ try {
3090
+ parsed = JSON.parse(raw);
3091
+ } catch {
3092
+ return null;
3093
+ }
3094
+ if (!Array.isArray(parsed)) return null;
3095
+ return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
3096
+ }
2709
3097
  async function updateLaunched(base, recordId, at) {
2710
3098
  const fields = { Status: "maintenance", "Launched at": at };
2711
3099
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
@@ -3363,9 +3751,32 @@ function mapRow2(rec) {
3363
3751
  deliveryStatus: f["Delivery status"] ?? "pending",
3364
3752
  renderedHtmlAttachment: html,
3365
3753
  resendMessageId: f["Resend message ID"] ?? null,
3366
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
3754
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
3755
+ autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
3367
3756
  };
3368
3757
  }
3758
+ function parseAutoEvidence(raw) {
3759
+ if (typeof raw !== "string" || !raw.trim()) return null;
3760
+ let parsed;
3761
+ try {
3762
+ parsed = JSON.parse(raw);
3763
+ } catch {
3764
+ return null;
3765
+ }
3766
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3767
+ const out = {};
3768
+ for (const [field, v] of Object.entries(parsed)) {
3769
+ if (!v || typeof v !== "object") continue;
3770
+ const o = v;
3771
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
3772
+ out[field] = {
3773
+ result: o.result,
3774
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
3775
+ note: typeof o.note === "string" ? o.note : ""
3776
+ };
3777
+ }
3778
+ return Object.keys(out).length > 0 ? out : null;
3779
+ }
3369
3780
  function lighthouseFromFields(f) {
3370
3781
  const p = f["Lighthouse \u2014 Performance"];
3371
3782
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -3399,6 +3810,10 @@ async function createDraft(base, input) {
3399
3810
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
3400
3811
  if (input.period !== void 0) fields["Period"] = input.period;
3401
3812
  if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
3813
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
3814
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
3815
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
3816
+ }
3402
3817
  const created = await base(REPORTS_TABLE).create([{ fields }]);
3403
3818
  const rec = created[0];
3404
3819
  if (!rec) throw new Error("Airtable create returned no records");
@@ -3467,6 +3882,118 @@ async function queueDraft(base, report) {
3467
3882
  return { queued: true, supersededIds };
3468
3883
  }
3469
3884
 
3885
+ // src/reports/auto-tick.ts
3886
+ var STALE_DAYS = 3;
3887
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
3888
+ function isFresh(checkedAt, now) {
3889
+ if (!checkedAt) return false;
3890
+ const t = new Date(checkedAt).getTime();
3891
+ if (Number.isNaN(t)) return false;
3892
+ return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
3893
+ }
3894
+ var CERT_MIN_DAYS = 14;
3895
+ function autoTickChecklist(site, reportType, now, signals) {
3896
+ const out = /* @__PURE__ */ new Map();
3897
+ const fields = new Set(checklistFor(reportType).map((i) => i.field));
3898
+ if (fields.has("Maint: Google Indexed")) {
3899
+ const g = googleEvidence(now, signals.search);
3900
+ if (g) out.set("Maint: Google Indexed", g);
3901
+ }
3902
+ if (fields.has("Maint: Security Updates")) {
3903
+ const s = securityEvidence(site, now);
3904
+ if (s) out.set("Maint: Security Updates", s);
3905
+ }
3906
+ if (fields.has("Maint: Domain, DNS & SSL")) {
3907
+ const d = domainEvidence(site, now);
3908
+ if (d) out.set("Maint: Domain, DNS & SSL", d);
3909
+ }
3910
+ if (fields.has("Test: Desktop Browsers")) {
3911
+ const e = browserEvidence(
3912
+ site.crossbrowserOk,
3913
+ site,
3914
+ now,
3915
+ "Desktop renders cleanly",
3916
+ "render errors"
3917
+ );
3918
+ if (e) out.set("Test: Desktop Browsers", e);
3919
+ }
3920
+ if (fields.has("Test: Mobile Browsers")) {
3921
+ const e = browserEvidence(
3922
+ site.mobileOk,
3923
+ site,
3924
+ now,
3925
+ "Mobile renders cleanly",
3926
+ "overflow/errors"
3927
+ );
3928
+ if (e) out.set("Test: Mobile Browsers", e);
3929
+ }
3930
+ if (fields.has("Test: Links & Navigation")) {
3931
+ const broken = site.brokenLinks;
3932
+ const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
3933
+ const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
3934
+ if (e) out.set("Test: Links & Navigation", e);
3935
+ }
3936
+ return out;
3937
+ }
3938
+ function browserEvidence(ok, site, now, passNote, failNote) {
3939
+ if (ok === null || !site.browserCheckedAt) return null;
3940
+ const at = site.browserCheckedAt;
3941
+ if (!isFresh(at, now)) {
3942
+ return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
3943
+ }
3944
+ return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
3945
+ }
3946
+ function securityEvidence(site, now) {
3947
+ const crit = site.securityVulnsCritical;
3948
+ const high = site.securityVulnsHigh;
3949
+ if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
3950
+ const at = site.lastSecurityAuditAt;
3951
+ if (!isFresh(at, now)) {
3952
+ return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
3953
+ }
3954
+ if (crit === 0 && high === 0) {
3955
+ return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
3956
+ }
3957
+ return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
3958
+ }
3959
+ function googleEvidence(now, search) {
3960
+ const at = now.toISOString();
3961
+ if (search.softFailed) {
3962
+ return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
3963
+ }
3964
+ if (search.value === null) return null;
3965
+ if (search.value.foundOnPage1) {
3966
+ const pos2 = search.value.position;
3967
+ return {
3968
+ result: "pass",
3969
+ checkedAt: at,
3970
+ note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
3971
+ };
3972
+ }
3973
+ const pos = search.value.position;
3974
+ return {
3975
+ result: "fail",
3976
+ checkedAt: at,
3977
+ note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
3978
+ };
3979
+ }
3980
+ function domainEvidence(site, now) {
3981
+ if (!site.url || isNetlifyAppUrl(site.url)) return null;
3982
+ if (!site.domainCheckedAt) return null;
3983
+ const at = site.domainCheckedAt;
3984
+ if (!isFresh(site.domainCheckedAt, now)) {
3985
+ return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
3986
+ }
3987
+ const days = site.certDaysRemaining;
3988
+ if (days === null) {
3989
+ return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
3990
+ }
3991
+ if (days <= CERT_MIN_DAYS) {
3992
+ return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
3993
+ }
3994
+ return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
3995
+ }
3996
+
3470
3997
  // src/reports/airtable/attachments.ts
3471
3998
  function looksLikeHtml(bytes) {
3472
3999
  const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
@@ -3538,7 +4065,7 @@ import { readFileSync as readFileSync3 } from "fs";
3538
4065
  import { JWT } from "google-auth-library";
3539
4066
  import { BetaAnalyticsDataClient } from "@google-analytics/data";
3540
4067
  var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
3541
- var MS_PER_DAY = 864e5;
4068
+ var MS_PER_DAY3 = 864e5;
3542
4069
  function ymd2(d) {
3543
4070
  return d.toISOString().slice(0, 10);
3544
4071
  }
@@ -3551,9 +4078,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
3551
4078
  subject: query.subject
3552
4079
  });
3553
4080
  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);
4081
+ const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
4082
+ const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
4083
+ const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
3557
4084
  const property = `properties/${query.propertyId}`;
3558
4085
  const run = async (start, end) => {
3559
4086
  const [resp] = await client.runReport({
@@ -3677,7 +4204,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3677
4204
  const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
3678
4205
  const periodEnd = today;
3679
4206
  const completedOn = today;
3680
- const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
4207
+ const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
3681
4208
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3682
4209
  const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3683
4210
  const gaUsers = gaResult.value;
@@ -3724,6 +4251,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3724
4251
  supersededIds: outcome2.supersededIds
3725
4252
  };
3726
4253
  }
4254
+ const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
4255
+ const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
4256
+ const autoEvidence = Object.fromEntries(evidence);
3727
4257
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
3728
4258
  const created = await createDraft(base, {
3729
4259
  reportId,
@@ -3737,7 +4267,9 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3737
4267
  lastTestedDate,
3738
4268
  ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
3739
4269
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
3740
- ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
4270
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
4271
+ checklistTicks,
4272
+ autoEvidence
3741
4273
  });
3742
4274
  await uploadDraftHtml(created.id, slug, periodEnd, html);
3743
4275
  const outcome = await queueDraft(base, {
@@ -3807,6 +4339,36 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
3807
4339
 
3808
4340
  // src/reports/airtable/client.ts
3809
4341
  import Airtable from "airtable";
4342
+
4343
+ // src/reports/airtable/throttle.ts
4344
+ function createMinIntervalThrottle(opts) {
4345
+ const { minIntervalMs, now, delay } = opts;
4346
+ return function wrap(fn) {
4347
+ let chain = Promise.resolve();
4348
+ let last = Number.NEGATIVE_INFINITY;
4349
+ return (...args) => {
4350
+ chain = chain.then(async () => {
4351
+ const wait = minIntervalMs - (now() - last);
4352
+ if (wait > 0) await delay(wait);
4353
+ last = now();
4354
+ fn(...args);
4355
+ }).catch(() => {
4356
+ });
4357
+ };
4358
+ };
4359
+ }
4360
+ function applyThrottle(base, opts) {
4361
+ const real = base._base?.runAction;
4362
+ if (typeof real !== "function") return base;
4363
+ const wrap = createMinIntervalThrottle(opts);
4364
+ const throttled = wrap(real.bind(base._base));
4365
+ base._base.runAction = throttled;
4366
+ base.runAction = throttled;
4367
+ return base;
4368
+ }
4369
+
4370
+ // src/reports/airtable/client.ts
4371
+ var MIN_REQUEST_INTERVAL_MS = 220;
3810
4372
  function missing(name) {
3811
4373
  return Object.assign(
3812
4374
  new Error(
@@ -3823,7 +4385,12 @@ function readAirtableConfig() {
3823
4385
  return { apiKey, baseId };
3824
4386
  }
3825
4387
  function openBase(cfg) {
3826
- return new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
4388
+ const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
4389
+ return applyThrottle(base, {
4390
+ minIntervalMs: MIN_REQUEST_INTERVAL_MS,
4391
+ now: () => Date.now(),
4392
+ delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
4393
+ });
3827
4394
  }
3828
4395
 
3829
4396
  // src/reports/maintenance-email/header-image.ts
@@ -4026,35 +4593,33 @@ async function sendOne(client, base, site, report) {
4026
4593
  });
4027
4594
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
4028
4595
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
4596
+ const attachments = [
4597
+ toInlineAttachment({
4598
+ bytes: header.bytes,
4599
+ filename: `${cidName}.jpg`,
4600
+ contentType: header.contentType,
4601
+ cid: cidName
4602
+ })
4603
+ ];
4604
+ for (const img of [bundled.check, bundled.blurred]) {
4605
+ if (html.includes(`cid:${img.cid}`)) {
4606
+ attachments.push(
4607
+ toInlineAttachment({
4608
+ bytes: img.bytes,
4609
+ filename: img.filename,
4610
+ contentType: img.contentType,
4611
+ cid: img.cid
4612
+ })
4613
+ );
4614
+ }
4615
+ }
4029
4616
  const payload = {
4030
4617
  from: FROM_ADDRESS,
4031
4618
  to,
4032
4619
  replyTo: REPLY_TO,
4033
4620
  subject,
4034
4621
  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
- ],
4622
+ attachments,
4058
4623
  // Stable across retries of the same row — if Airtable stamping fails after a
4059
4624
  // successful Resend, the next --send-ready replays with the same key and
4060
4625
  // Resend returns the original message id rather than sending a duplicate.
@@ -4244,6 +4809,28 @@ function securitySub(site) {
4244
4809
  const l = site.securityVulnsLow ?? 0;
4245
4810
  return `${c}C / ${h}H / ${m}M / ${l}L`;
4246
4811
  }
4812
+ function advisoryRow(a) {
4813
+ const sev = escapeHtml(a.severity);
4814
+ const module = escapeHtml(a.module);
4815
+ const title = a.title ? ` \u2014 ${escapeHtml(a.title)}` : "";
4816
+ const cves = a.cves.length > 0 ? ` <span class="muted">(${escapeHtml(a.cves.join(", "))})</span>` : "";
4817
+ const link = a.url ? ` <a href="${escapeHtml(safeUrl(a.url))}" rel="noopener noreferrer">advisory \u25B8</a>` : "";
4818
+ return `<li class="vuln-item">
4819
+ <span class="pill sev-${sev}">${sev}</span>
4820
+ <strong>${module}</strong>${title}${cves}${link}
4821
+ </li>`;
4822
+ }
4823
+ function securitySection(site) {
4824
+ const advisories = site.securityAdvisories;
4825
+ if (!advisories || advisories.length === 0) return "";
4826
+ const sorted = [...advisories].sort(
4827
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
4828
+ );
4829
+ return `<div class="section vulns">
4830
+ <h2>Vulnerabilities (${sorted.length})</h2>
4831
+ <ul class="vuln-list">${sorted.map(advisoryRow).join("")}</ul>
4832
+ </div>`;
4833
+ }
4247
4834
  function checklistBlock(r) {
4248
4835
  const items = checklistFor(r.reportType);
4249
4836
  if (items.length === 0) return "";
@@ -4251,7 +4838,9 @@ function checklistBlock(r) {
4251
4838
  const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
4252
4839
  const boxes = items.map((item) => {
4253
4840
  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>`;
4841
+ const ev = r.autoEvidence?.[item.field];
4842
+ 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>` : "";
4843
+ 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
4844
  }).join("");
4256
4845
  return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
4257
4846
  }
@@ -4296,30 +4885,79 @@ function reportRow(r) {
4296
4885
  const action = isPendingApproval(r) ? approveButton(r) : "";
4297
4886
  return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;
4298
4887
  }
4888
+ function extraFieldsList(raw) {
4889
+ if (!raw || raw.trim() === "") return "";
4890
+ let parsed;
4891
+ try {
4892
+ parsed = JSON.parse(raw);
4893
+ } catch {
4894
+ return `<div class="subm-kv"><span class="k">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;
4895
+ }
4896
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4897
+ return `<div class="subm-kv"><span class="k">Extra fields</span> <code>${escapeHtml(raw)}</code></div>`;
4898
+ }
4899
+ const rows = Object.entries(parsed).map(
4900
+ ([k, v]) => `<div class="subm-kv"><span class="k">${escapeHtml(k)}</span> ${escapeHtml(String(v))}</div>`
4901
+ ).join("");
4902
+ return rows;
4903
+ }
4299
4904
  function submissionRow(s) {
4300
4905
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
4301
4906
  const type = escapeHtml(s.formType);
4302
4907
  const who = escapeHtml(s.name || "(no name)");
4303
4908
  const email = escapeHtml(s.email || "");
4304
- const message = escapeHtml(s.message ?? "");
4305
4909
  const status = escapeHtml(s.status);
4306
4910
  const id = escapeHtml(s.id);
4307
4911
  const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;
4308
4912
  const btn = (label, action) => `<button class="subm-status" data-id="${id}" data-status="${action}" data-url="${url}">${label}</button>`;
4913
+ const kv = (label, value) => value === null || value === "" ? "" : `<div class="subm-kv"><span class="k">${label}</span> ${escapeHtml(String(value))}</div>`;
4914
+ const sourceLink = s.sourceUrl ? `<div class="subm-kv"><span class="k">Source</span> <a href="${escapeHtml(safeUrl(s.sourceUrl))}" rel="noopener noreferrer">${escapeHtml(s.sourceUrl)}</a></div>` : "";
4915
+ const messageBlock = s.message ? `<div class="subm-kv"><span class="k">Message</span></div><div class="subm-msg">${escapeHtml(s.message)}</div>` : "";
4916
+ const details = [
4917
+ kv("Phone", s.phone),
4918
+ messageBlock,
4919
+ sourceLink,
4920
+ kv("UTM", s.utm),
4921
+ extraFieldsList(s.extraFields),
4922
+ kv("Notify", s.notifyStatus),
4923
+ kv("Resend ID", s.resendMessageId),
4924
+ kv("Submission #", s.submissionId)
4925
+ ].join("");
4309
4926
  return `<li class="subm-item">
4310
- <div class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span> <span class="muted">${when}</span></div>
4311
- ${message ? `<div class="subm-msg">${message}</div>` : ""}
4927
+ <details>
4928
+ <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span> <span class="muted">${when}</span></summary>
4929
+ <div class="subm-detail">${details}</div>
4930
+ </details>
4312
4931
  <div class="subm-actions">${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
4313
4932
  </li>`;
4314
4933
  }
4934
+ var SUBMISSIONS_PER_SITE_CAP = 25;
4315
4935
  function submissionsSection(submissions) {
4316
4936
  if (submissions.length === 0) return "";
4317
- const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, 25);
4937
+ const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
4938
+ const note = submissions.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${submissions.length}</span>` : "";
4318
4939
  return `<div class="section submissions">
4319
- <h2>Form submissions (${submissions.length})</h2>
4940
+ <h2>Form submissions (${submissions.length})${note}</h2>
4320
4941
  <ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
4321
4942
  </div>`;
4322
4943
  }
4944
+ var SPAM_WINDOW_DAYS = 30;
4945
+ function spamScreenSection(totals, submissions, now) {
4946
+ const sinceMs = now.getTime() - SPAM_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
4947
+ const delivered = submissions.filter(
4948
+ (s) => s.submittedAt !== null && Date.parse(s.submittedAt) >= sinceMs
4949
+ ).length;
4950
+ const t = totals ?? { honeypot: 0, tooFast: 0, markedSpam: 0 };
4951
+ if (delivered === 0 && t.honeypot === 0 && t.tooFast === 0 && t.markedSpam === 0) return "";
4952
+ const row = (label, n) => `<div class="spam-kv"><span class="k">${label}</span> ${escapeHtml(String(n))}</div>`;
4953
+ return `<div class="section spam-screen">
4954
+ <h2>Spam screen (30d)</h2>
4955
+ ${row("Caught \u2014 honeypot", t.honeypot)}
4956
+ ${row("Caught \u2014 too-fast", t.tooFast)}
4957
+ ${row("Delivered", delivered)}
4958
+ ${row("Marked spam", t.markedSpam)}
4959
+ </div>`;
4960
+ }
4323
4961
  function setupSection(site) {
4324
4962
  const { score, total } = onboardingStatus(site);
4325
4963
  const missing2 = missingOnboarding(site);
@@ -4378,19 +5016,35 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
4378
5016
  .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
4379
5017
  .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
4380
5018
  .check-item input { margin: 0; }
5019
+ .auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }
5020
+ .auto-pass { background: #e6f4ea; color: #137333; }
5021
+ .auto-amber { background: #fef7e0; color: #b06000; }
4381
5022
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4382
5023
  .subm-list { list-style: none; padding: 0; margin: 0; }
4383
5024
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }
4384
5025
  @media (prefers-color-scheme: dark) { .subm-item { border-color: #2a2a2a; } }
4385
5026
  .subm-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
4386
5027
  .subm-msg { margin: 0.35rem 0; white-space: pre-wrap; }
5028
+ .subm-detail { padding: 0.35rem 0 0.2rem; }
5029
+ .subm-kv { font-size: 0.9rem; margin: 0.15rem 0; }
5030
+ .subm-kv .k { color: #888; margin-right: 0.4rem; }
5031
+ summary.subm-head { cursor: pointer; }
4387
5032
  .subm-actions { display: flex; gap: 0.4rem; }
4388
5033
  button.subm-status { font: inherit; padding: 0.25rem 0.7rem; border: 1px solid #888; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
4389
5034
  button.subm-status:disabled { opacity: 0.6; cursor: default; }
5035
+ .spam-screen .spam-kv { font-size: 0.95rem; margin: 0.2rem 0; }
5036
+ .spam-screen .spam-kv .k { color: #888; display: inline-block; min-width: 11rem; }
4390
5037
  .pill.subm-new { background: #e8f0fe; color: #1a56db; }
4391
5038
  .pill.subm-read { background: #f0f0f0; color: #555; }
4392
5039
  .pill.subm-archived { background: #eee; color: #888; }
4393
5040
  .pill.subm-spam { background: #fdecea; color: #b00; }
5041
+ .vuln-list { list-style: none; padding: 0; margin: 0; }
5042
+ .vuln-item { padding: 0.45rem 0; border-bottom: 1px solid #eee; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; }
5043
+ @media (prefers-color-scheme: dark) { .vuln-item { border-color: #2a2a2a; } }
5044
+ .pill.sev-critical { background: #fdecea; color: #b00; }
5045
+ .pill.sev-high { background: #fff0e6; color: #c4500a; }
5046
+ .pill.sev-moderate { background: #fff8e1; color: #8a6d00; }
5047
+ .pill.sev-low { background: #f0f0f0; color: #555; }
4394
5048
  .home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }
4395
5049
  .setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }
4396
5050
  .setup-ok { color: #1b7a2f; font-weight: 600; }
@@ -4400,7 +5054,7 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
4400
5054
  .detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
4401
5055
  .detail dd { margin: 0; }
4402
5056
  `;
4403
- function renderSiteDashboardHtml(site, reports, submissions = []) {
5057
+ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = null, now = /* @__PURE__ */ new Date()) {
4404
5058
  const name = escapeHtml(site.name);
4405
5059
  const urlSafe = safeUrl(site.url);
4406
5060
  const allScoresNull = site.pScore === null && site.rScore === null && site.bpScore === null && site.seoScore === null;
@@ -4451,6 +5105,10 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4451
5105
  ${healthSection}
4452
5106
  </div>
4453
5107
 
5108
+ ${securitySection(site)}
5109
+
5110
+ ${spamScreenSection(spamTotals, submissions, now)}
5111
+
4454
5112
  <div class="section">
4455
5113
  <h2>Reports</h2>
4456
5114
  ${reportsSection}
@@ -4610,6 +5268,8 @@ h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
4610
5268
  .summary { display:flex; flex-wrap:wrap; gap:0.5rem 1.25rem; align-items:baseline; margin-bottom:0.5rem; }
4611
5269
  .summary .tier { font-weight:700; }
4612
5270
  .summary .heads { color:#666; font-size:0.9rem; }
5271
+ .spam-rollup { font-size:0.9rem; margin-bottom:1rem; }
5272
+ .muted { color:#999; }
4613
5273
  .filters { display:flex; flex-wrap:wrap; gap:0.4rem; margin-bottom:1.25rem; }
4614
5274
  .filters button { font:inherit; font-size:0.85rem; padding:0.25rem 0.7rem; border:1px solid #ccc; border-radius:999px; background:transparent; color:inherit; cursor:pointer; }
4615
5275
  .filters button[aria-pressed="true"] { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
@@ -4671,6 +5331,11 @@ function summaryBar(model) {
4671
5331
  <div class="summary heads">${escapeHtml(heads)}</div>
4672
5332
  <div class="filters">${chips2}</div>`;
4673
5333
  }
5334
+ function spamRollup(model) {
5335
+ const s = model.spam;
5336
+ if (!s || s.caught === 0 && s.through === 0) return "";
5337
+ return `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${s.caught} \xB7 through ${s.through}</div>`;
5338
+ }
4674
5339
  function allClearBanner(model) {
4675
5340
  if (model.summary.attention > 0) return "";
4676
5341
  const msg = model.cards.length === 0 ? "No sites on the fleet view yet." : "All clear \u2014 nothing needs your attention.";
@@ -4693,10 +5358,12 @@ function approveStrip(model) {
4693
5358
  ${rows}
4694
5359
  </section>`;
4695
5360
  }
5361
+ var SUBMISSIONS_STRIP_CAP = 10;
4696
5362
  function submissionsStrip(model) {
4697
5363
  const subs = model.submissions ?? [];
4698
5364
  if (subs.length === 0) return "";
4699
- const rows = subs.map((sub) => {
5365
+ const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
5366
+ const rows = shown.map((sub) => {
4700
5367
  const href = `/s/${escapeHtml(sub.slug)}`;
4701
5368
  const when = sub.submittedAt ? escapeHtml(relativeTimeFromNow(sub.submittedAt)) : "";
4702
5369
  const who = escapeHtml(sub.name || sub.email);
@@ -4707,9 +5374,11 @@ function submissionsStrip(model) {
4707
5374
  <a href="${href}">open \u25B8</a>
4708
5375
  </div>`;
4709
5376
  }).join("");
5377
+ const overflow = subs.length - shown.length;
5378
+ const more = overflow > 0 ? `<div class="approve-row subm-more muted">+${overflow} more \u2014 triage on each site page</div>` : "";
4710
5379
  return `<section class="approve-strip subm-strip" data-tier="submissions">
4711
5380
  <h2>\u{1F4E5} New submissions (${subs.length})</h2>
4712
- ${rows}
5381
+ ${rows}${more}
4713
5382
  </section>`;
4714
5383
  }
4715
5384
  function submBadge(c) {
@@ -4802,6 +5471,7 @@ function renderCockpitHtml(model) {
4802
5471
  <h1>Reddoor fleet cockpit</h1>
4803
5472
  <div class="meta">${total} site${total === 1 ? "" : "s"} on the Reddoor stack.</div>
4804
5473
  ${summaryBar(model)}
5474
+ ${spamRollup(model)}
4805
5475
  ${allClearBanner(model)}
4806
5476
  ${approveStrip(model)}
4807
5477
  ${submissionsStrip(model)}