@reddoorla/maintenance 0.72.0 → 0.74.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.
Files changed (106) hide show
  1. package/dist/airtable-6L3JD7FY.js +9 -0
  2. package/dist/{announce-HJCGEVJA.js → announce-V6EYPPUR.js} +4 -4
  3. package/dist/{blux-FKZYMKLF.js → blux-OL5HKP26.js} +950 -69
  4. package/dist/blux-OL5HKP26.js.map +1 -0
  5. package/dist/{bump-deps-GZEJAUKI.js → bump-deps-OQR3IXLN.js} +2 -2
  6. package/dist/chunk-2OP4JKIQ.js +19 -0
  7. package/dist/chunk-2OP4JKIQ.js.map +1 -0
  8. package/dist/{chunk-BJCA7O4A.js → chunk-3PU2QWOJ.js} +2 -2
  9. package/dist/{chunk-GI4M2AMO.js → chunk-4BHNP6Y2.js} +2 -2
  10. package/dist/{chunk-KRHPS3R4.js → chunk-6OYICQSJ.js} +47 -3
  11. package/dist/chunk-6OYICQSJ.js.map +1 -0
  12. package/dist/{chunk-BWKK2ZXS.js → chunk-GXVX4HUH.js} +32 -2
  13. package/dist/chunk-GXVX4HUH.js.map +1 -0
  14. package/dist/{chunk-KLB2OLPW.js → chunk-HBFMTM2E.js} +3 -3
  15. package/dist/{chunk-SQJ53Z7R.js → chunk-I55YWV6T.js} +3 -3
  16. package/dist/{chunk-7P7YETHG.js → chunk-IDBMYDVH.js} +2 -2
  17. package/dist/{chunk-JGLAWXUC.js → chunk-JR67FZVC.js} +7 -3
  18. package/dist/chunk-JR67FZVC.js.map +1 -0
  19. package/dist/{chunk-APTAITXZ.js → chunk-KKL3RYFQ.js} +2 -2
  20. package/dist/{chunk-NRISG4XD.js → chunk-NUIBQPB3.js} +42 -11
  21. package/dist/chunk-NUIBQPB3.js.map +1 -0
  22. package/dist/{chunk-BHE5CUWI.js → chunk-PERP7LSS.js} +269 -18
  23. package/dist/chunk-PERP7LSS.js.map +1 -0
  24. package/dist/chunk-RHPDQW6D.js +29 -0
  25. package/dist/chunk-RHPDQW6D.js.map +1 -0
  26. package/dist/{chunk-HR5SONJC.js → chunk-SK44IFZN.js} +2 -2
  27. package/dist/cli/bin.js +31 -19
  28. package/dist/cli/bin.js.map +1 -1
  29. package/dist/cli/commands/audit.js +6 -6
  30. package/dist/{convert-to-pnpm-SVFDLXZS.js → convert-to-pnpm-OVMASATZ.js} +2 -2
  31. package/dist/{digest-VX7Q4MNF.js → digest-LEMZS5ZK.js} +99 -7
  32. package/dist/digest-LEMZS5ZK.js.map +1 -0
  33. package/dist/{ensure-site-O7G5ZJS7.js → ensure-site-3SOAWTZF.js} +2 -2
  34. package/dist/forms/index.js +1 -1
  35. package/dist/{github-signals-QVW2LB7N.js → github-signals-TF3VSNIN.js} +4 -4
  36. package/dist/{health-endpoint-IYR5VBUT.js → health-endpoint-JZGYCMTV.js} +2 -2
  37. package/dist/index.d.ts +104 -19
  38. package/dist/index.js +122 -30
  39. package/dist/index.js.map +1 -1
  40. package/dist/{init-CU6PVZRG.js → init-7PTP4O2K.js} +4 -4
  41. package/dist/{launch-JWXCTJRG.js → launch-W33FWWOZ.js} +6 -6
  42. package/dist/{lighthouse-airtable-J5TLASJC.js → lighthouse-airtable-ZXNMDEPE.js} +3 -3
  43. package/dist/{onboard-6UQE5QIO.js → onboard-PX36YDZA.js} +2 -2
  44. package/dist/{orchestrate-ZDH24Z6Y.js → orchestrate-NYRYEKQM.js} +3 -3
  45. package/dist/{preflight-BNTMWTPG.js → preflight-JCQFKB6M.js} +4 -4
  46. package/dist/{renovate-dispatch-C3ALLACX.js → renovate-dispatch-6II2JVOX.js} +29 -27
  47. package/dist/renovate-dispatch-6II2JVOX.js.map +1 -0
  48. package/dist/{report-YLFSGNWR.js → report-POHQEV6F.js} +21 -5
  49. package/dist/report-POHQEV6F.js.map +1 -0
  50. package/dist/screenouts-7P5GOZMV.js +57 -0
  51. package/dist/screenouts-7P5GOZMV.js.map +1 -0
  52. package/dist/{self-updating-64FNRQB5.js → self-updating-2YSVNMPP.js} +2 -2
  53. package/dist/{selftest-7DPNGL6R.js → selftest-OO2WDSDM.js} +5 -5
  54. package/dist/{smoke-suite-TWILWDSF.js → smoke-suite-CSP23NZM.js} +2 -2
  55. package/dist/spam-classifier-VG4ICIWS.js +217 -0
  56. package/dist/spam-classifier-VG4ICIWS.js.map +1 -0
  57. package/dist/submissions-BNRGY3JQ.js +122 -0
  58. package/dist/submissions-BNRGY3JQ.js.map +1 -0
  59. package/dist/submissions-MDKWJYMM.js +281 -0
  60. package/dist/submissions-MDKWJYMM.js.map +1 -0
  61. package/dist/{svelte-codemods-RVDTTXFD.js → svelte-codemods-V54KOBWU.js} +2 -2
  62. package/dist/{sync-configs-BUUD6L5Q.js → sync-configs-UBCJRASZ.js} +2 -2
  63. package/dist/{upgrade-HHKWYJRN.js → upgrade-WR6ICPAG.js} +2 -2
  64. package/dist/{websites-EH2ULELZ.js → websites-U6642XSE.js} +10 -2
  65. package/dist/{write-audits-to-airtable-LZDYTNX5.js → write-audits-to-airtable-22T6D733.js} +4 -4
  66. package/package.json +1 -1
  67. package/dist/airtable-U2GTDTK5.js +0 -9
  68. package/dist/blux-FKZYMKLF.js.map +0 -1
  69. package/dist/chunk-BHE5CUWI.js.map +0 -1
  70. package/dist/chunk-BWKK2ZXS.js.map +0 -1
  71. package/dist/chunk-JGLAWXUC.js.map +0 -1
  72. package/dist/chunk-KRHPS3R4.js.map +0 -1
  73. package/dist/chunk-NRISG4XD.js.map +0 -1
  74. package/dist/chunk-QTHKOIFJ.js +0 -13
  75. package/dist/chunk-QTHKOIFJ.js.map +0 -1
  76. package/dist/digest-VX7Q4MNF.js.map +0 -1
  77. package/dist/renovate-dispatch-C3ALLACX.js.map +0 -1
  78. package/dist/report-YLFSGNWR.js.map +0 -1
  79. /package/dist/{airtable-U2GTDTK5.js.map → airtable-6L3JD7FY.js.map} +0 -0
  80. /package/dist/{announce-HJCGEVJA.js.map → announce-V6EYPPUR.js.map} +0 -0
  81. /package/dist/{bump-deps-GZEJAUKI.js.map → bump-deps-OQR3IXLN.js.map} +0 -0
  82. /package/dist/{chunk-BJCA7O4A.js.map → chunk-3PU2QWOJ.js.map} +0 -0
  83. /package/dist/{chunk-GI4M2AMO.js.map → chunk-4BHNP6Y2.js.map} +0 -0
  84. /package/dist/{chunk-KLB2OLPW.js.map → chunk-HBFMTM2E.js.map} +0 -0
  85. /package/dist/{chunk-SQJ53Z7R.js.map → chunk-I55YWV6T.js.map} +0 -0
  86. /package/dist/{chunk-7P7YETHG.js.map → chunk-IDBMYDVH.js.map} +0 -0
  87. /package/dist/{chunk-APTAITXZ.js.map → chunk-KKL3RYFQ.js.map} +0 -0
  88. /package/dist/{chunk-HR5SONJC.js.map → chunk-SK44IFZN.js.map} +0 -0
  89. /package/dist/{convert-to-pnpm-SVFDLXZS.js.map → convert-to-pnpm-OVMASATZ.js.map} +0 -0
  90. /package/dist/{ensure-site-O7G5ZJS7.js.map → ensure-site-3SOAWTZF.js.map} +0 -0
  91. /package/dist/{github-signals-QVW2LB7N.js.map → github-signals-TF3VSNIN.js.map} +0 -0
  92. /package/dist/{health-endpoint-IYR5VBUT.js.map → health-endpoint-JZGYCMTV.js.map} +0 -0
  93. /package/dist/{init-CU6PVZRG.js.map → init-7PTP4O2K.js.map} +0 -0
  94. /package/dist/{launch-JWXCTJRG.js.map → launch-W33FWWOZ.js.map} +0 -0
  95. /package/dist/{lighthouse-airtable-J5TLASJC.js.map → lighthouse-airtable-ZXNMDEPE.js.map} +0 -0
  96. /package/dist/{onboard-6UQE5QIO.js.map → onboard-PX36YDZA.js.map} +0 -0
  97. /package/dist/{orchestrate-ZDH24Z6Y.js.map → orchestrate-NYRYEKQM.js.map} +0 -0
  98. /package/dist/{preflight-BNTMWTPG.js.map → preflight-JCQFKB6M.js.map} +0 -0
  99. /package/dist/{self-updating-64FNRQB5.js.map → self-updating-2YSVNMPP.js.map} +0 -0
  100. /package/dist/{selftest-7DPNGL6R.js.map → selftest-OO2WDSDM.js.map} +0 -0
  101. /package/dist/{smoke-suite-TWILWDSF.js.map → smoke-suite-CSP23NZM.js.map} +0 -0
  102. /package/dist/{svelte-codemods-RVDTTXFD.js.map → svelte-codemods-V54KOBWU.js.map} +0 -0
  103. /package/dist/{sync-configs-BUUD6L5Q.js.map → sync-configs-UBCJRASZ.js.map} +0 -0
  104. /package/dist/{upgrade-HHKWYJRN.js.map → upgrade-WR6ICPAG.js.map} +0 -0
  105. /package/dist/{websites-EH2ULELZ.js.map → websites-U6642XSE.js.map} +0 -0
  106. /package/dist/{write-audits-to-airtable-LZDYTNX5.js.map → write-audits-to-airtable-22T6D733.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reports/digest.ts"],"sourcesContent":["// src/reports/digest.ts\nimport { openBase, readAirtableConfig, type AirtableBase } from \"./airtable/client.js\";\nimport { listAllReports, isPendingApproval } from \"./airtable/reports.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { listWebsites, siteSlug, type WebsiteRow } from \"./airtable/websites.js\";\nimport { defaultResendClient, type ResendClient } from \"./send/resend.js\";\nimport { isIdempotencyConflict } from \"./send/idempotency.js\";\nimport {\n collectVulnAlerts,\n collectDeliveryFailures,\n collectPreflightBlocked,\n collectLighthouseAlerts,\n collectRenovateAlerts,\n collectCiAlerts,\n collectAnalyticsFailures,\n collectTurnstileGuardrailAlerts,\n collectNotifyBounceAlerts,\n NOTIFY_BOUNCE_WINDOW_DAYS,\n} from \"../alerts/digest-collectors.js\";\nimport { diffAttention, readDigestState, writeDigestState } from \"../alerts/digest-state.js\";\nimport { escapeHtml as esc } from \"../util/html.js\";\nimport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n// Type-only: erased at compile time, so the kysely/libsql devDependency never loads\n// in a consuming fleet site (the runtime read is the dynamic import in\n// fetchSubmissionsDigestCounts, same rule as fetchNotifyBounceCounts).\nimport type { SiteSubmissionCounts } from \"../db/submissions.js\";\n\n// The attention/digest contract lives in `../alerts/attention.ts` (a dependency-free\n// types module) so the `alerts/*` collectors can depend on it without importing back\n// from this renderer/IO module — see attention.ts for the cycle it breaks. Re-exported\n// here so existing `from \"./digest.js\"` type importers keep resolving.\nexport type {\n AttentionItem,\n AttentionSeverity,\n AttentionStatus,\n ReadyItem,\n DigestSections,\n SubmissionsDigestSection,\n} from \"../alerts/attention.js\";\n\nconst GREY = \"#757575\";\nconst RED = \"#C00\";\n\n/** Shared anchor style — Gmail does not inherit font-family into <a> tags. */\nconst ANCHOR_STYLE = `color:${RED};font-family:helvetica,sans-serif`;\n\nfunction readySection(items: ReadyItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Ready for your yes</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">Nothing waiting on you.</p>`;\n }\n const rows = items\n .map((it) => {\n const safeUrl = it.dashboardUrl.startsWith(\"https://\") ? it.dashboardUrl : undefined;\n const link = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">review &amp; approve</a>`\n : `review &amp; approve`;\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(it.siteName)}</strong> — ${esc(it.reportType)} (${esc(it.period)})\n — ${link}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${rows}</table>`;\n}\n\nconst SEVERITY_ORDER: Record<AttentionSeverity, number> = { critical: 0, warning: 1 };\n\n/** Render the per-item status badge (\"NEW\"/\"WORSE\"); standing items get nothing. */\nfunction attentionBadge(status?: AttentionStatus): string {\n if (status === \"new\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">NEW</strong> `;\n if (status === \"worse\")\n return `<strong style=\"color:${RED};font-family:helvetica,sans-serif\">WORSE</strong> `;\n return \"\";\n}\n\nfunction attentionSection(items: AttentionItem[]): string {\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Needs attention</h2>`;\n if (items.length === 0) {\n return `${heading}<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">All clear — nothing needs attention.</p>`;\n }\n\n // Group by siteName, preserving first-seen site order; sort within a site by\n // severity (critical first).\n const bySite = new Map<string, AttentionItem[]>();\n for (const it of items) {\n const bucket = bySite.get(it.siteName);\n if (bucket) bucket.push(it);\n else bySite.set(it.siteName, [it]);\n }\n\n const groups = [...bySite.entries()]\n .map(([siteName, siteItems]) => {\n const sorted = [...siteItems].sort(\n (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],\n );\n const rows = sorted\n .map((it) => {\n const safeUrl = it.url?.startsWith(\"https://\") ? it.url : undefined;\n const titleHtml = safeUrl\n ? `<a href=\"${esc(safeUrl)}\" style=\"${ANCHOR_STYLE}\">${esc(it.title)}</a>`\n : esc(it.title);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">${attentionBadge(it.status)}${titleHtml}</td>\n </tr>`;\n })\n .join(\"\");\n return `\n <tr>\n <td style=\"color:#222;font-family:helvetica,sans-serif;font-size:16px;font-weight:700;padding:8px 0 4px\">${esc(siteName)}</td>\n </tr>\n ${rows}`;\n })\n .join(\"\");\n\n return `${heading}<table role=\"presentation\" style=\"border-collapse:collapse;margin:0\">${groups}</table>`;\n}\n\n/** The \"Submissions (24h)\" telemetry block: fleet totals + a per-site glance line.\n * Context, not an alarm — absent/null section or all-zero totals render nothing\n * (no-noise), and the section never triggers a send on its own (see runDigest). */\nfunction submissionsSection(s: SubmissionsDigestSection | null | undefined): string {\n if (!s) return \"\";\n if (s.leads === 0 && s.signups === 0 && s.spamAuto === 0) return \"\";\n const heading = `<h2 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px\">Submissions (24h)</h2>`;\n // All three terms always render together — omitting a zero term next to a nonzero\n // one reads as \"not measured\" rather than \"none\"; simplest honest form.\n const totals = [\n `${s.leads} new lead${s.leads === 1 ? \"\" : \"s\"}`,\n `${s.signups} newsletter/RSVP signup${s.signups === 1 ? \"\" : \"s\"}`,\n `${s.spamAuto} auto-filtered spam`,\n ].join(\" · \");\n const totalLine = `<p style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;margin:0\">${totals}</p>`;\n if (s.bySite.length === 0) return `${heading}${totalLine}`;\n const rows = s.bySite\n .map((site) => {\n const parts: string[] = [];\n if (site.leads > 0) parts.push(`${site.leads} lead${site.leads === 1 ? \"\" : \"s\"}`);\n if (site.signups > 0) parts.push(`${site.signups} signup${site.signups === 1 ? \"\" : \"s\"}`);\n if (site.spamAuto > 0) parts.push(`${site.spamAuto} auto-filtered`);\n return `\n <tr>\n <td style=\"color:${GREY};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px\">\n <strong style=\"color:#222\">${esc(site.siteName)}</strong> — ${parts.join(\" · \")}\n </td>\n </tr>`;\n })\n .join(\"\");\n return `${heading}${totalLine}<table role=\"presentation\" style=\"border-collapse:collapse;margin:8px 0 0\">${rows}</table>`;\n}\n\nconst FROM_ADDRESS = \"Reddoor Reports <reports@reddoorla.com>\";\n/** Single-operator fleet — fallback when OPERATOR_EMAIL is unset. */\nconst DIGEST_OPERATOR_FALLBACK = \"info@reddoorla.com\";\n\n/** UTC \"YYYY-MM-DD\" — the Resend idempotency key suffix, so a same-day cron re-fire dedupes. */\nfunction digestDateKey(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * The gate for \"Ready for your yes\": Draft ready ∧ ¬Approved to send ∧ Sent at BLANK.\n *\n * Implemented as `listAllReports(base).filter(...)` (the authorized deviation from the plan's\n * draft, which pre-dated Slice 1's merged fix): `listAllReports` already calls `mapRow`, which\n * handles `Period` correctly, so no local `rawToReportRow` duplicate is needed. The JS filter\n * does real work — the test fake does NOT evaluate `filterByFormula` — so correctness is\n * test-provable here.\n *\n * Exported: the fleet homepage (Task 3.5b) reuses it for the pending-approval count.\n */\nexport async function listPendingApproval(base: AirtableBase): Promise<ReportRow[]> {\n return (await listAllReports(base)).filter(isPendingApproval);\n}\n\n// ── collectAttention (IO wrapper, sibling to runDigest) ──────────────────────\n\nexport type CollectAttentionDeps = {\n base: AirtableBase;\n /** Same baseUrl value runDigest threads; used for the /s/<slug> links. */\n baseUrl: string;\n /** Pre-fetched Websites rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listWebsites`. */\n websites?: WebsiteRow[];\n /** Pre-fetched Reports rows. When supplied (runDigest already read them),\n * collectAttention reuses them instead of issuing a second `listAllReports`. */\n reports?: ReportRow[];\n /** Clock for the GitHub-signals staleness gate (collectCiAlerts /\n * collectRenovateAlerts skip a >3-day-stale sweep). Defaults to wall-clock;\n * runDigest threads its run-start `today` so a quiet repo's frozen CI/Renovate\n * signal stops badging once its sweep goes stale. */\n now?: Date;\n /** Pre-fetched per-site bounced-lead-notification counts (tests, or a caller with\n * an open db). When omitted, collectAttention reads them from libSQL itself —\n * defensively, so a missing TURSO env or a Turso blip drops just this signal. */\n notifyBounces?: ReadonlyMap<string, number>;\n};\n\n/** Per-site bounced-notification counts for the notify-bounce collector, read from\n * libSQL over the collector's own window. The db modules are imported DYNAMICALLY:\n * src/db/submissions.ts pulls kysely at top level, and kysely/libsql live in\n * devDependencies consuming fleet sites don't install — a static import here would\n * crash their CLI at require time (same rule as openDb's own lazy loads). Any\n * failure (no TURSO env, Turso down) logs and yields an empty map: the digest\n * must never blank over a missing optional signal. */\nasync function fetchNotifyBounceCounts(now: Date): Promise<ReadonlyMap<string, number>> {\n try {\n const [{ openDb, readDbConfig }, { countNotifyBouncedBySite }, { screenOutsSince }] =\n await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n import(\"../db/screenouts.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countNotifyBouncedBySite(db, screenOutsSince(now, NOTIFY_BOUNCE_WINDOW_DAYS));\n } catch (e) {\n console.warn(`⚠ notify-bounce counts unavailable (libSQL): ${(e as Error).message}`);\n return new Map();\n }\n}\n\n/** The \"Submissions (24h)\" telemetry window — a precise 24h ISO-timestamp compare\n * (unlike screenOutsSince's date-only strings). */\nconst SUBMISSIONS_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/** Per-site submission counts for the digest telemetry section, read from libSQL.\n * Dynamic imports + fail-soft for the same reason as fetchNotifyBounceCounts:\n * kysely/libsql are devDependencies consuming fleet sites don't install, and a\n * Turso blip must drop just this section, never the digest. Returns null (not an\n * empty map) on failure so the renderer can omit the section rather than claim a\n * quiet fleet. */\nasync function fetchSubmissionsDigestCounts(\n now: Date,\n): Promise<ReadonlyMap<string, SiteSubmissionCounts> | null> {\n try {\n const [{ openDb, readDbConfig }, { countSubmissionsSinceBySite }] = await Promise.all([\n import(\"../db/client.js\"),\n import(\"../db/submissions.js\"),\n ]);\n const db = await openDb(readDbConfig());\n return await countSubmissionsSinceBySite(\n db,\n new Date(now.getTime() - SUBMISSIONS_DIGEST_WINDOW_MS).toISOString(),\n );\n } catch (e) {\n console.warn(`⚠ submissions telemetry unavailable (libSQL): ${(e as Error).message}`);\n return null;\n }\n}\n\n/** Assemble the \"Submissions (24h)\" section from the per-site counts. PURE (exported\n * for direct tests). Totals sum ALL map entries — an orphan site id still counts in\n * the fleet totals. bySite lists only ids that resolve in `sitesById` AND carry any\n * nonzero count, sorted by total volume desc then siteName A-Z. null counts (libSQL\n * unavailable) → null (section omitted). */\nexport function buildSubmissionsDigestSection(\n counts: ReadonlyMap<string, SiteSubmissionCounts> | null,\n sitesById: ReadonlyMap<string, WebsiteRow>,\n): SubmissionsDigestSection | null {\n if (counts === null) return null;\n let leads = 0;\n let signups = 0;\n let spamAuto = 0;\n const bySite: SubmissionsDigestSection[\"bySite\"] = [];\n for (const [siteId, c] of counts) {\n leads += c.leads;\n signups += c.signups;\n spamAuto += c.spamAuto;\n const site = sitesById.get(siteId);\n if (site && c.leads + c.signups + c.spamAuto > 0) {\n bySite.push({ siteName: site.name, ...c });\n }\n }\n bySite.sort(\n (a, b) =>\n b.leads + b.signups + b.spamAuto - (a.leads + a.signups + a.spamAuto) ||\n a.siteName.localeCompare(b.siteName),\n );\n return { leads, signups, spamAuto, bySite };\n}\n\n/** Run a single collector under a try/catch: a thrown collector logs and yields []\n * so one broken signal never blanks the whole \"Needs attention\" section. */\nfunction runCollector(label: string, fn: () => AttentionItem[]): AttentionItem[] {\n try {\n return fn();\n } catch (e) {\n console.warn(`⚠ attention collector \"${label}\" failed: ${(e as Error).message}`);\n return [];\n }\n}\n\n/**\n * Fetch the free signals once (listAllReports + listWebsites) — or reuse the\n * `reports`/`websites` arrays runDigest already read, so a single run reads each\n * table once — build the sitesById map the delivery collector needs, and run each\n * pure collector isolated. Returns the union of items; diffing/badging happens in\n * runDigest.\n *\n * The Renovate + CI signals come from the SAME persisted collectors the operator\n * cockpit (`buildCockpitModel`) runs — `collectRenovateAlerts` (key\n * `renovate:<siteId>`) and `collectCiAlerts` (key `ci:<siteId>`), reading the\n * nightly-persisted `renovateFailingCis`/`defaultBranchCi` fields. This is the\n * key-space unification: because the digest writes the shared Digest State\n * snapshot with these same keys, the cockpit's NEW/WORSE diff finds them and the\n * two surfaces agree (the prior live per-PR `renovate:<repo>#<n>` sweep never\n * matched the cockpit's keys, so its cards badged NEW forever).\n */\nexport async function collectAttention(deps: CollectAttentionDeps): Promise<AttentionItem[]> {\n const reports = deps.reports ?? (await listAllReports(deps.base));\n const websites = deps.websites ?? (await listWebsites(deps.base));\n const now = deps.now ?? new Date();\n const notifyBounces = deps.notifyBounces ?? (await fetchNotifyBounceCounts(now));\n const sitesById = new Map<string, WebsiteRow>(websites.map((w) => [w.id, w]));\n return [\n ...runCollector(\"vuln\", () => collectVulnAlerts(websites, deps.baseUrl)),\n ...runCollector(\"delivery\", () => collectDeliveryFailures(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"preflight\", () => collectPreflightBlocked(reports, sitesById, deps.baseUrl)),\n ...runCollector(\"lighthouse\", () => collectLighthouseAlerts(websites, deps.baseUrl)),\n ...runCollector(\"renovate\", () => collectRenovateAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"ci\", () => collectCiAlerts(websites, deps.baseUrl, now)),\n ...runCollector(\"analytics\", () => collectAnalyticsFailures(websites, deps.baseUrl, now)),\n ...runCollector(\"turnstile\", () =>\n collectTurnstileGuardrailAlerts(websites, deps.baseUrl, now),\n ),\n ...runCollector(\"notify-bounce\", () =>\n collectNotifyBounceAlerts(websites, notifyBounces, deps.baseUrl),\n ),\n ];\n}\n\nexport type DigestRunOptions = {\n resend?: ResendClient;\n /** Dashboard origin for the /s/<slug> links, e.g. \"https://reddoor-maintenance.netlify.app\". */\n baseUrl: string;\n /**\n * Inject a pre-opened Airtable base (tests, server handlers).\n * When omitted, `openBase(readAirtableConfig())` is called from the environment.\n */\n base?: AirtableBase;\n /** Per-site submission counts for the \"Submissions (24h)\" section. undefined =\n * fetch from libSQL; null = simulate unavailable (section omitted); a Map =\n * injected (tests). */\n submissionCounts?: ReadonlyMap<string, SiteSubmissionCounts> | null;\n};\n\nexport async function runDigest(\n options: DigestRunOptions,\n): Promise<{ output: string; code: number }> {\n // Capture clock BEFORE any await so the idempotency key can't roll past midnight mid-run.\n const today = new Date();\n try {\n const base = options.base ?? openBase(readAirtableConfig());\n // Read each table ONCE for the whole run, then thread the arrays into\n // collectAttention so it doesn't re-fetch (was: listWebsites ×2, listAllReports\n // ×2). Pending is derived in-line with listPendingApproval's exact predicate.\n const reports = await listAllReports(base);\n const websites = await listWebsites(base);\n const sites = new Map(websites.map((w) => [w.id, w]));\n\n const pending = reports.filter(isPendingApproval);\n\n const readyForYourYes: ReadyItem[] = [];\n const baseUrl = options.baseUrl.replace(/\\/$/, \"\");\n for (const r of pending) {\n const site = sites.get(r.siteId);\n if (!site) continue; // orphan report → skip rather than render a broken link\n // An empty Name slugs to \"\" → `/s/` is a dead link (getWebsiteBySlug can't\n // match it). Fall back to the fleet homepage so the operator still lands\n // somewhere usable instead of a 404.\n const slug = siteSlug(site.name);\n readyForYourYes.push({\n siteName: site.name,\n reportType: r.reportType,\n period: r.period ?? \"—\",\n dashboardUrl: slug ? `${baseUrl}/s/${slug}` : baseUrl,\n });\n }\n\n // M5: collect the free signals (isolated), diff against yesterday's snapshot.\n // Renovate + CI come from the persisted collectors (the same ones the cockpit\n // runs), so the snapshot this digest writes carries the `renovate:<siteId>` /\n // `ci:<siteId>` keys the cockpit diffs against — no live GitHub sweep here.\n const collected = await collectAttention({\n base,\n baseUrl: options.baseUrl,\n websites,\n reports,\n now: today,\n });\n const prior = await readDigestState(base);\n const { tagged, next } = diffAttention(collected, prior, digestDateKey(today));\n const needsAttention = tagged;\n\n // No-noise default: skip entirely when there's nothing to report.\n if (readyForYourYes.length === 0 && needsAttention.length === 0) {\n // On a skip, `collected` is [] so `next` is {} — still persist it so a key that\n // resolved on a quiet day clears and a later recurrence diffs as NEW (spec §10).\n // Wrapped: a write failure can't fail the skip.\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: \"Digest skipped (nothing ready, nothing needs attention).\", code: 0 };\n }\n\n // Submissions telemetry rides only on a digest that is ALREADY sending — the\n // skip predicate above is deliberately unchanged (leads already fire their own\n // ingest-time notification; this is context, not an alarm), and fetching AFTER\n // the skip check keeps quiet days from touching libSQL at all.\n const counts =\n options.submissionCounts !== undefined\n ? options.submissionCounts\n : await fetchSubmissionsDigestCounts(today);\n const submissions = buildSubmissionsDigestSection(counts, sites);\n\n const html = renderDigestHtml({ readyForYourYes, needsAttention, submissions });\n const client = options.resend ?? defaultResendClient();\n const to = [process.env.OPERATOR_EMAIL?.trim() || DIGEST_OPERATOR_FALLBACK];\n const n = readyForYourYes.length;\n const reportWord = n === 1 ? \"report\" : \"reports\";\n let result: Awaited<ReturnType<typeof client.send>>;\n try {\n result = await client.send({\n from: FROM_ADDRESS,\n to,\n subject: `Your fleet — ${digestDateKey(today)}: ${n} ${reportWord} ready for your yes`,\n html,\n idempotencyKey: `digest-${digestDateKey(today)}`,\n });\n } catch (err) {\n // A same-UTC-day re-run whose content changed re-sends with the same\n // `digest-<date>` idempotency key but a DIFFERENT body. Resend rejects that\n // with a 409 (`invalid_idempotent_request`) — \"This idempotency key has been\n // used ... but the request body was modified ...\". The operator already got\n // today's digest on the first send, so re-sending a changed version same-day\n // would just be a duplicate: treat it as an \"already sent today\" no-op.\n //\n // ResendClient (send/resend.ts) wraps the API error as a plain Error and only\n // preserves the *message* string (no name/statusCode), so the message\n // substring is the only reliable discriminator — match defensively on it.\n // Any OTHER send error re-throws to the outer catch → {code:1}, so a genuine\n // Resend/network failure still fails loudly.\n if (isIdempotencyConflict(err)) {\n // Do NOT write the snapshot: the first send already persisted it; writing\n // this run's `next` would diff against the first run's snapshot and mis-badge.\n return {\n output:\n \"Digest already sent today (content changed since the first send) — skipped to avoid a duplicate.\",\n code: 0,\n };\n }\n throw err;\n }\n // Persist the next snapshot AFTER a successful send. A write failure is caught +\n // logged: the digest already went out, tomorrow re-news at worst. (The send-FAILURE\n // path never reaches here — the outer catch returns code 1 with no write, preserving\n // the NEW badge for the retry.)\n try {\n await writeDigestState(base, next);\n } catch (e) {\n console.warn(`⚠ digest state write failed: ${(e as Error).message}`);\n }\n return { output: `Digest sent to ${to.join(\", \")} (${result.messageId})`, code: 0 };\n } catch (err) {\n // Re-throw config errors (exitCode=2: missing env vars, bad config) so runOrExit\n // surfaces them with the correct process exit code rather than collapsing to 1.\n if (typeof (err as { exitCode?: unknown }).exitCode === \"number\") {\n throw err;\n }\n const message = err instanceof Error ? err.message : String(err);\n return { output: `digest failed: ${message}`, code: 1 };\n }\n}\n\n/** Pure render of the unified daily operator digest. No IO — the caller (runDigest)\n * collects the rows and decides whether to send. */\nexport function renderDigestHtml(sections: DigestSections): string {\n return `<!doctype html>\n<html>\n <head><meta charset=\"utf-8\"></head>\n <body style=\"margin:0;padding:0;background:#ffffff\">\n <table width=\"100%\" style=\"border-collapse:collapse\">\n <tr>\n <td align=\"center\" style=\"padding:24px\">\n <table width=\"600\" style=\"border-collapse:collapse\">\n <tr>\n <td>\n <h1 style=\"color:${RED};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px\">Your fleet today</h1>\n ${readySection(sections.readyForYourYes)}\n ${attentionSection(sections.needsAttention)}\n ${submissionsSection(sections.submissions)}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
@@ -2,7 +2,7 @@ import {
2
2
  WEBSITES_TABLE,
3
3
  listWebsites,
4
4
  siteSlug
5
- } from "./chunk-BWKK2ZXS.js";
5
+ } from "./chunk-GXVX4HUH.js";
6
6
  import {
7
7
  openBase,
8
8
  readAirtableConfig
@@ -84,4 +84,4 @@ async function runEnsureSiteCommand(slug, opts) {
84
84
  export {
85
85
  runEnsureSiteCommand
86
86
  };
87
- //# sourceMappingURL=ensure-site-O7G5ZJS7.js.map
87
+ //# sourceMappingURL=ensure-site-3SOAWTZF.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SUBMISSION_FORM_TYPES
3
- } from "../chunk-QTHKOIFJ.js";
3
+ } from "../chunk-2OP4JKIQ.js";
4
4
 
5
5
  // src/forms/client.ts
6
6
  var INGEST_TIMEOUT_MS = 8e3;
@@ -3,8 +3,8 @@ import {
3
3
  } from "./chunk-PZQ3WED2.js";
4
4
  import {
5
5
  formatFleetWriteSummary
6
- } from "./chunk-JGLAWXUC.js";
7
- import "./chunk-BJCA7O4A.js";
6
+ } from "./chunk-JR67FZVC.js";
7
+ import "./chunk-3PU2QWOJ.js";
8
8
  import {
9
9
  detectSignalEvents,
10
10
  fleetSweptEvent
@@ -22,7 +22,7 @@ import {
22
22
  listWebsites,
23
23
  siteSlug,
24
24
  updateGitHubSignals
25
- } from "./chunk-BWKK2ZXS.js";
25
+ } from "./chunk-GXVX4HUH.js";
26
26
  import {
27
27
  openBase,
28
28
  readAirtableConfig
@@ -134,4 +134,4 @@ export {
134
134
  githubSignalsExitCode,
135
135
  runGitHubSignalsCommand
136
136
  };
137
- //# sourceMappingURL=github-signals-QVW2LB7N.js.map
137
+ //# sourceMappingURL=github-signals-TF3VSNIN.js.map
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-4NAOR44V.js";
9
9
  import {
10
10
  resolveSites
11
- } from "./chunk-GI4M2AMO.js";
11
+ } from "./chunk-4BHNP6Y2.js";
12
12
  import {
13
13
  healthEndpoint
14
14
  } from "./chunk-7MJBTV2R.js";
@@ -51,4 +51,4 @@ async function runHealthEndpointCommand(site, opts) {
51
51
  export {
52
52
  runHealthEndpointCommand
53
53
  };
54
- //# sourceMappingURL=health-endpoint-IYR5VBUT.js.map
54
+ //# sourceMappingURL=health-endpoint-JZGYCMTV.js.map
package/dist/index.d.ts CHANGED
@@ -46,15 +46,22 @@ type DiscoverDeps = {
46
46
  * opened — no extra navigation). */
47
47
  type RouteResult = {
48
48
  url: string;
49
- /** Per desktop engine (chromium/firefox/webkit): loaded with no JS error + a visible main landmark. */
49
+ /** Per desktop engine (chromium/firefox/webkit): loaded with no JS error + a visible main
50
+ * landmark. `status` = the HTTP status of THAT engine's navigation (null = the nav threw:
51
+ * timeout/crash); optional because it wasn't always captured — undefined is never excusable.
52
+ * NOTE: `ok` may be an EXCUSAL, not proof of a render — excuseChallengedEngineChecks flips a
53
+ * WAF-challenged entry to ok:true on a reachable route (see details.excusedEngineChecks). */
50
54
  desktop: Array<{
51
55
  engine: string;
52
56
  ok: boolean;
57
+ status?: number | null;
53
58
  }>;
54
- /** Per mobile device: loaded with no JS error and no horizontal overflow. */
59
+ /** Per mobile device: loaded with no JS error and no horizontal overflow. Same `status`
60
+ * semantics as desktop, and the same excusal caveat on `ok`. */
55
61
  mobile: Array<{
56
62
  device: string;
57
63
  ok: boolean;
64
+ status?: number | null;
58
65
  }>;
59
66
  /** Same-origin links discovered on the page (absolute URLs), for the Links check. */
60
67
  links: string[];
@@ -75,6 +82,16 @@ type BrowserRunner = {
75
82
  checkLinks: (urls: string[]) => Promise<LinkResult[]>;
76
83
  close?: () => Promise<void>;
77
84
  };
85
+ /** What a plain (non-browser) GET of a route observed. `status: null` = network error/timeout. */
86
+ type PageFetch = {
87
+ status: number | null;
88
+ title: string | null;
89
+ metaDescription: string | null;
90
+ };
91
+ /** Injected plain-fetch re-verification IO (see reverifyRoutes). Tests pass a fake. */
92
+ type VerifyDeps = {
93
+ fetchPage: (url: string) => Promise<PageFetch>;
94
+ };
78
95
 
79
96
  /** Outcome of driving one site's contact form. `formPresent:false` ⇒ n/a
80
97
  * (persisted). `testModeUndeclared` ⇒ the site's /health does not declare
@@ -210,6 +227,10 @@ type AuditContext = {
210
227
  discoverDeps?: DiscoverDeps;
211
228
  /** Playwright runner injection for the browser audit (tests). Defaults to real Playwright. */
212
229
  browserRunner?: BrowserRunner;
230
+ /** Plain-fetch re-verification injection for the browser audit (tests). Defaults to real fetch.
231
+ * Re-checks any route the browser probe called unreachable / title-less before a fail verdict
232
+ * can persist (WAF challenges hit headless browsers, not plain fetches). */
233
+ verifyDeps?: VerifyDeps;
213
234
  /** Playwright runner injection for the form-e2e audit (tests). Defaults to real Playwright. */
214
235
  formRunner?: FormRunner;
215
236
  /** Netlify API injection for the netlify-deploy audit (tests). Defaults to a real API call. */
@@ -377,7 +398,7 @@ type AirtableInventoryOptions = {
377
398
  declare function fromAirtableBase(base: AirtableBase, opts?: AirtableInventoryOptions): InventoryProvider;
378
399
 
379
400
  type Frequency = "None" | "Monthly" | "Quarterly" | "Yearly";
380
- type Status = "in development" | "launch period" | "maintenance" | "hosting" | "probably not our problem" | "deprecated";
401
+ type Status = "in development" | "launch period" | "maintenance" | "hosting" | "probably not our problem" | "deprecated" | "legacy";
381
402
  /**
382
403
  * Per-site notification routing. When present on a `maintenance` site, the form
383
404
  * notification is addressed by the value of a submission field (`field`, read from
@@ -504,8 +525,15 @@ type WebsiteRow = {
504
525
  * "ok"`. Single-select `pass`/`fail`; null = never ran. No per-site Prismic token or identity
505
526
  * column is ever built — this rides `/health`. */
506
527
  cmsReachable: "pass" | "fail" | null;
507
- /** When the `function-health` audit last ran the freshness gate for BOTH `functionHealth` and
508
- * `cmsReachable`. Null = never ran. */
528
+ /** From the same `/health` body's `forms.turnstile` boolean: does the deployed site report
529
+ * the Turnstile widget configured (`PUBLIC_TURNSTILE_SITE_KEY` set)? Single-select
530
+ * `pass`/`fail`; null = never ran / older site package whose `/health` has no `forms`
531
+ * block. Freshness gated by `functionHealthCheckedAt`. Powers the cockpit Require-Turnstile
532
+ * guardrail: a site with `requireTurnstile` ON but no widget silently buckets 100% of its
533
+ * real leads (and form-e2e can't see it — testMode bypasses the gate). */
534
+ turnstileWidget: "pass" | "fail" | null;
535
+ /** When the `function-health` audit last ran — the freshness gate for `functionHealth`,
536
+ * `cmsReachable`, and `turnstileWidget`. Null = never ran. */
509
537
  functionHealthCheckedAt: string | null;
510
538
  /** Deployed-URL browser probe (the `browser` audit): cross-engine render OK, mobile render OK,
511
539
  * internal-links OK + broken count, and when it last ran (one timestamp gates all three). */
@@ -534,9 +562,16 @@ type WebsiteRow = {
534
562
  * blank → skipped. The API key is `key-dc` format; dc is derived from it. */
535
563
  mailchimpApiKey: string | null;
536
564
  mailchimpAudienceId: string | null;
537
- /** Per-site Cloudflare Turnstile gate. When true, a submission whose Turnstile
538
- * token verifies as "fail" is escalated to auto-spam regardless of content score
539
- * (an absent/unverifiable token stays neutral fail-open). Airtable checkbox. */
565
+ /** Per-site Cloudflare Turnstile gate (Airtable checkbox). When true, a submission
566
+ * whose Turnstile token verifies as "fail" (forged) OR "absent" (secret configured
567
+ * centrally but NO token forwardedthe direct-POST-bot signature) is escalated to
568
+ * auto-spam regardless of content score. A present-but-expired token stays
569
+ * "unverifiable" and neutral (a real browser DID render the widget — fail-open).
570
+ * ROLLOUT PRECONDITION: only enable on a site whose DEPLOYED package forwards
571
+ * `_meta.turnstileToken` from EVERY form (widget rendered + `cf-turnstile-response`
572
+ * posted; check the site's `/health` → forms.turnstile:true). A site that never
573
+ * forwards tokens would silently bucket 100% of its real leads — and the form-e2e
574
+ * probe cannot catch that state (testMode bypasses the gate). */
540
575
  requireTurnstile: boolean;
541
576
  /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */
542
577
  renovateFailingCis: number | null;
@@ -752,6 +787,19 @@ type DraftResult = {
752
787
  html: string;
753
788
  /** Enrichment fetches that errored for this site (empty on success or skip). */
754
789
  softFailures: SoftFailure[];
790
+ /** True when search enrichment fell back to the site name (no explicit `Search query`)
791
+ * AND that default matched nothing in Search Console — the signal that this site needs
792
+ * a hand-tuned brand query. Distinct from a soft-failure/outage: the fetch succeeded, it
793
+ * just found no data for the name. Requires a property to have RESOLVED — a site with no
794
+ * Search Console property at all raises `searchPropertyMissing` instead (a query change
795
+ * can't fix that, and prescribing one would silence the real problem). */
796
+ searchDefaultMissed: boolean;
797
+ /** True when NO Search Console property matched the site for any subject — a
798
+ * missing/unverified property or lost service-account access. Fires for BOTH explicit
799
+ * and name-default queries (unlike `searchDefaultMissed`), because no query can succeed
800
+ * without a property. Distinct from a soft-failure: the API worked, the property list
801
+ * just contained nothing for this site. */
802
+ searchPropertyMissing: boolean;
755
803
  /** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier
756
804
  * report is already queued for the site (single-queue rule); null on the previewOnly path. */
757
805
  queued: boolean | null;
@@ -878,7 +926,7 @@ declare function selfCaretRange(callerImportMetaUrl: string): string;
878
926
 
879
927
  declare const SUBMISSION_STATUSES: readonly ["new", "read", "archived", "spam", "spam_auto"];
880
928
  type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
881
- declare const NOTIFY_STATUSES: readonly ["sent", "failed", "skipped"];
929
+ declare const NOTIFY_STATUSES: readonly ["sent", "failed", "skipped", "bounced"];
882
930
  type NotifyStatus = (typeof NOTIFY_STATUSES)[number];
883
931
  type SubmissionRow = {
884
932
  id: string;
@@ -909,14 +957,6 @@ type ScreenOutTotals = {
909
957
  markedSpam: number;
910
958
  };
911
959
 
912
- /**
913
- * Render the per-site dashboard as a single HTML document. Pure function:
914
- * no Airtable access, no env reads, no I/O. The Netlify function handler
915
- * fetches data, then hands it here. Easier to unit-test, easier to render
916
- * a static preview from CLI later.
917
- */
918
- declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[], spamTotals?: ScreenOutTotals | null, now?: Date): string;
919
-
920
960
  /** Severity of a "Needs attention" entry. `critical` sorts above `warning`. */
921
961
  type AttentionSeverity = "critical" | "warning";
922
962
  /** Set by `diffAttention` before render: how this item changed since the prior digest. */
@@ -930,7 +970,7 @@ type AttentionStatus = "new" | "worse" | "standing";
930
970
  type AttentionItem = {
931
971
  /** Stable identity for diffing: `vuln:<siteId>`, `delivery:<reportId>`. */
932
972
  key: string;
933
- kind: "vuln" | "delivery" | "renovate" | "lighthouse" | "ci" | "analytics" | "preflight";
973
+ kind: "vuln" | "delivery" | "renovate" | "lighthouse" | "ci" | "analytics" | "preflight" | "turnstile" | "notify-bounce";
934
974
  /** Grouping key in the (component-3) render. */
935
975
  siteName: string;
936
976
  title: string;
@@ -957,6 +997,10 @@ type SiteCard = {
957
997
  items: AttentionItem[];
958
998
  /** Why the site is on Watch — human labels (empty unless tier === "watch"). */
959
999
  watchReasons: string[];
1000
+ /** Primary accept token per watch reason, index-aligned with `watchReasons` — the
1001
+ * exact string the operator can add to Accepted Watch Conditions to mute it. Optional
1002
+ * for back-compat with hand-built card fixtures; the renderer falls back to no hint. */
1003
+ watchAcceptKeys?: string[];
960
1004
  /** Structured watch tags ("lighthouse" / "stale") for the client filter. */
961
1005
  watchSignals: string[];
962
1006
  /** Watch reasons the operator has accepted: suppressed from the band, shown as a
@@ -964,6 +1008,10 @@ type SiteCard = {
964
1008
  acceptedReasons: string[];
965
1009
  /** Count of NEW submissions for this site (optional; populated by buildCockpitModel). */
966
1010
  newSubmissions?: number;
1011
+ /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups (2026-07-16).
1012
+ * Optional for back-compat with hand-built card fixtures. */
1013
+ newLeads?: number;
1014
+ newSignups?: number;
967
1015
  };
968
1016
  type PendingEntry = {
969
1017
  reportId: string;
@@ -985,7 +1033,9 @@ type CockpitSummary = {
985
1033
  attention: number;
986
1034
  watch: number;
987
1035
  healthy: number;
988
- /** Count of pre-live "launch period" sites muted, never counted as broken. */
1036
+ /** Count of pre-live "launch period" sites still muted as expected pre-launch
1037
+ * conditions. A piercing alarm (piercesPreLaunchMute) re-tiers such a site to
1038
+ * attention, so it counts there instead — muted never means "hidden if broken". */
989
1039
  preLaunch: number;
990
1040
  criticalHighVulns: number;
991
1041
  lighthouseBelowFloor: number;
@@ -997,6 +1047,10 @@ type CockpitSummary = {
997
1047
  pending: number;
998
1048
  /** Count of NEW submissions across the fleet (optional for back-compat). */
999
1049
  newSubmissions?: number;
1050
+ /** Split of newSubmissions: actionable leads vs newsletter/rsvp signups
1051
+ * (optional for back-compat, 2026-07-16). */
1052
+ newLeads?: number;
1053
+ newSignups?: number;
1000
1054
  };
1001
1055
  /** A render-ready row for the cockpit "Recently" lane. `url` is an external link
1002
1056
  * (PR) when present; `slug` links to `/s/<slug>` when the event names a site. */
@@ -1008,6 +1062,13 @@ type RecentEntry = {
1008
1062
  url: string | null;
1009
1063
  ts: string;
1010
1064
  };
1065
+ /** A Websites row surfaced OUTSIDE the fleet cards: archived (legacy/deprecated)
1066
+ * or holding an unrecognized Status cell. `status` is the raw cell value. */
1067
+ type OffFleetSiteEntry = {
1068
+ name: string;
1069
+ slug: string;
1070
+ status: string;
1071
+ };
1011
1072
  type CockpitModel = {
1012
1073
  summary: CockpitSummary;
1013
1074
  /** All visible sites, ordered: attention (worst-first) → watch (A-Z) → healthy (A-Z). */
@@ -1025,7 +1086,31 @@ type CockpitModel = {
1025
1086
  /** Fleet-wide count of submissions auto-filtered as spam in the affordance window
1026
1087
  * (optional for back-compat). Drives the cockpit "N auto-filtered this week" line. */
1027
1088
  autoFiltered?: number;
1089
+ /** Archived (legacy/deprecated) rows — excluded from every fleet op, listed on a
1090
+ * neutral collapsed lane so a row can never silently vanish (optional for back-compat). */
1091
+ archived?: OffFleetSiteEntry[];
1092
+ /** Rows whose Status cell is outside the code's union (typo / renamed option) —
1093
+ * invisible to every fleet op, so the Needs-you feed surfaces them as amber watch
1094
+ * rows (optional for back-compat). */
1095
+ unrecognizedStatus?: OffFleetSiteEntry[];
1028
1096
  };
1097
+ /** The cockpit's alarm verdict for ONE site, for the /s/<slug> page header. */
1098
+ type SiteAlarmContext = {
1099
+ tier: Tier;
1100
+ /** This site's attention items, severity-sorted, status UNTAGGED (no diffAttention). */
1101
+ items: AttentionItem[];
1102
+ watchReasons: string[];
1103
+ watchAcceptKeys: string[];
1104
+ acceptedReasons: string[];
1105
+ };
1106
+
1107
+ /**
1108
+ * Render the per-site dashboard as a single HTML document. Pure function:
1109
+ * no Airtable access, no env reads, no I/O. The Netlify function handler
1110
+ * fetches data, then hands it here. Easier to unit-test, easier to render
1111
+ * a static preview from CLI later.
1112
+ */
1113
+ declare function renderSiteDashboardHtml(site: WebsiteRow, reports: ReportRow[], submissions?: SubmissionRow[], spamTotals?: ScreenOutTotals | null, now?: Date, alarm?: SiteAlarmContext | null): string;
1029
1114
 
1030
1115
  /**
1031
1116
  * Render the fleet cockpit as a single HTML document. Pure function: no Airtable