@reddoorla/maintenance 0.85.2 → 0.86.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/README.md +8 -0
- package/dist/airtable-G7WBQQMI.js +9 -0
- package/dist/{announce-GIAYN67S.js → announce-MXTUQ6C7.js} +26 -19
- package/dist/announce-MXTUQ6C7.js.map +1 -0
- package/dist/{bump-deps-SWJB6H5V.js → bump-deps-EC74CPLS.js} +9 -9
- package/dist/chunk-2EVEFOZY.js +129 -0
- package/dist/chunk-2EVEFOZY.js.map +1 -0
- package/dist/{chunk-COZPL62X.js → chunk-2NDRHXMR.js} +25 -17
- package/dist/chunk-2NDRHXMR.js.map +1 -0
- package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
- package/dist/chunk-2NQUCS7O.js +243 -0
- package/dist/chunk-2NQUCS7O.js.map +1 -0
- package/dist/{chunk-DTTIFYAM.js → chunk-2ZPT7GLV.js} +42 -403
- package/dist/chunk-2ZPT7GLV.js.map +1 -0
- package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
- package/dist/chunk-3SF7D4DY.js +359 -0
- package/dist/chunk-3SF7D4DY.js.map +1 -0
- package/dist/{chunk-Q276MZ7E.js → chunk-3VQWSE3F.js} +4 -4
- package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
- package/dist/chunk-5UINJNAJ.js.map +1 -0
- package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
- package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
- package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
- package/dist/chunk-ACBRLLFT.js +198 -0
- package/dist/chunk-ACBRLLFT.js.map +1 -0
- package/dist/chunk-BNM5POSJ.js +284 -0
- package/dist/chunk-BNM5POSJ.js.map +1 -0
- package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
- package/dist/{chunk-MGR2H7F4.js → chunk-BYK66EWO.js} +19 -12
- package/dist/chunk-BYK66EWO.js.map +1 -0
- package/dist/{chunk-6QRLRT3P.js → chunk-CQR4LIM7.js} +12 -265
- package/dist/chunk-CQR4LIM7.js.map +1 -0
- package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
- package/dist/chunk-DJZWYIPP.js.map +1 -0
- package/dist/chunk-EVBCK2KC.js +68 -0
- package/dist/chunk-EVBCK2KC.js.map +1 -0
- package/dist/{chunk-TP5M2NQB.js → chunk-F5KM4LEQ.js} +3 -3
- package/dist/{chunk-62VF32IC.js → chunk-ILUNZHYV.js} +2 -2
- package/dist/chunk-ITIOFN7A.js +396 -0
- package/dist/chunk-ITIOFN7A.js.map +1 -0
- package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
- package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
- package/dist/{chunk-PBWDDDQH.js → chunk-KTH5ZODA.js} +2 -2
- package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
- package/dist/chunk-LBYOLBW7.js.map +1 -0
- package/dist/{chunk-577HCGZO.js → chunk-LDWZSLSU.js} +38 -23
- package/dist/chunk-LDWZSLSU.js.map +1 -0
- package/dist/{chunk-LTB6KVJW.js → chunk-N45M3FDK.js} +13 -11
- package/dist/{chunk-LTB6KVJW.js.map → chunk-N45M3FDK.js.map} +1 -1
- package/dist/chunk-NYUYNYQL.js +48 -0
- package/dist/chunk-NYUYNYQL.js.map +1 -0
- package/dist/{chunk-7J53SUT6.js → chunk-P2FE5AI6.js} +4 -4
- package/dist/{chunk-3YXDSCM6.js → chunk-PPE5KZJK.js} +29 -11
- package/dist/chunk-PPE5KZJK.js.map +1 -0
- package/dist/chunk-PTDMVGKL.js +31 -0
- package/dist/chunk-PTDMVGKL.js.map +1 -0
- package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
- package/dist/chunk-PZR4EBPY.js.map +1 -0
- package/dist/{chunk-NWB4O5EY.js → chunk-SLUQ7R6I.js} +3 -3
- package/dist/chunk-SLUQ7R6I.js.map +1 -0
- package/dist/{chunk-GZNBCHAZ.js → chunk-TERE5SDF.js} +4 -4
- package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
- package/dist/chunk-UDRHAQT7.js +172 -0
- package/dist/chunk-UDRHAQT7.js.map +1 -0
- package/dist/chunk-VAD43MOR.js +72 -0
- package/dist/chunk-VAD43MOR.js.map +1 -0
- package/dist/chunk-VSBFM7DS.js +229 -0
- package/dist/chunk-VSBFM7DS.js.map +1 -0
- package/dist/chunk-W7Z3BGCI.js +274 -0
- package/dist/chunk-W7Z3BGCI.js.map +1 -0
- package/dist/chunk-X3B723J4.js +1209 -0
- package/dist/chunk-X3B723J4.js.map +1 -0
- package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
- package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
- package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
- package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
- package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
- package/dist/{chunk-MPBXOJ3N.js → chunk-Z7AXYKQB.js} +33 -2
- package/dist/chunk-Z7AXYKQB.js.map +1 -0
- package/dist/{chunk-WYYQK4D3.js → chunk-ZKXSX4LO.js} +11 -11
- package/dist/cli/bin.js +46 -28
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +30 -1
- package/dist/cli/commands/audit.js +62 -30
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-AUE3XUPO.js +11 -0
- package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-6L6P74WR.js} +9 -9
- package/dist/db-B35OGV3G.js +217 -0
- package/dist/db-B35OGV3G.js.map +1 -0
- package/dist/{digest-UVHYZUYE.js → digest-BL6UJYKH.js} +23 -20
- package/dist/{digest-UVHYZUYE.js.map → digest-BL6UJYKH.js.map} +1 -1
- package/dist/dump-P6EBSIOH.js +68 -0
- package/dist/dump-P6EBSIOH.js.map +1 -0
- package/dist/email-VTTTKKL5.js +195 -0
- package/dist/email-VTTTKKL5.js.map +1 -0
- package/dist/{ensure-site-S25MDDHW.js → ensure-site-DFTB7MN3.js} +8 -7
- package/dist/ensure-site-DFTB7MN3.js.map +1 -0
- package/dist/fleet-events-cF2Om88w.d.ts +24 -0
- package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-75U6OU7L.js} +38 -132
- package/dist/forms-notify-target-75U6OU7L.js.map +1 -0
- package/dist/{github-signals-G4ILZAJ6.js → github-signals-IJUPGCUX.js} +40 -19
- package/dist/github-signals-IJUPGCUX.js.map +1 -0
- package/dist/{header-image-T7XD37JL.js → header-image-AWLLQA4L.js} +34 -11
- package/dist/header-image-AWLLQA4L.js.map +1 -0
- package/dist/header-images-335L2AKB.js +68 -0
- package/dist/header-images-335L2AKB.js.map +1 -0
- package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-WJC2UMYS.js} +9 -9
- package/dist/health-mirror-4B2L7T2Y.js +75 -0
- package/dist/health-mirror-4B2L7T2Y.js.map +1 -0
- package/dist/import-airtable-HE43FB4H.js +35 -0
- package/dist/index.d.ts +175 -21
- package/dist/index.js +115 -73
- package/dist/index.js.map +1 -1
- package/dist/{init-JN26NTWF.js → init-NJEOEO7I.js} +20 -17
- package/dist/{init-JN26NTWF.js.map → init-NJEOEO7I.js.map} +1 -1
- package/dist/{launch-JA7Z4CIM.js → launch-XNAX427U.js} +31 -25
- package/dist/{launch-JA7Z4CIM.js.map → launch-XNAX427U.js.map} +1 -1
- package/dist/lighthouse-VHI77PFB.js +11 -0
- package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-LBVIYYP5.js} +3 -3
- package/dist/mailchimp-XYCTEEJY.js +72 -0
- package/dist/mailchimp-XYCTEEJY.js.map +1 -0
- package/dist/migrate-GQH237A6.js +7 -0
- package/dist/notify-C645PGD5.js +22 -0
- package/dist/{onboard-53CITIM7.js → onboard-GK4PHPNI.js} +9 -9
- package/dist/orchestrate-77UO4BYJ.js +30 -0
- package/dist/orchestrate-77UO4BYJ.js.map +1 -0
- package/dist/parity-MWS24A3H.js +12 -0
- package/dist/parity-MWS24A3H.js.map +1 -0
- package/dist/pipeline-S6CVHDGB.js +12 -0
- package/dist/pipeline-S6CVHDGB.js.map +1 -0
- package/dist/{preflight-3XPDUHFJ.js → preflight-6WBNU2GZ.js} +16 -13
- package/dist/{preflight-3XPDUHFJ.js.map → preflight-6WBNU2GZ.js.map} +1 -1
- package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-3RHOZUUT.js} +15 -15
- package/dist/{prismic-models-PVES6R5V.js → prismic-models-QBGUT2Q3.js} +10 -10
- package/dist/prospect-audit-IUNCJ2LV.js +174 -0
- package/dist/prospect-audit-IUNCJ2LV.js.map +1 -0
- package/dist/prospect-audits-3PMIO73D.js +19 -0
- package/dist/prospect-audits-3PMIO73D.js.map +1 -0
- package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/render-AQAVQDPZ.js +10 -0
- package/dist/render-AQAVQDPZ.js.map +1 -0
- package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-QJY75ODR.js} +8 -8
- package/dist/replay-C2LXMJZP.js +66 -0
- package/dist/replay-C2LXMJZP.js.map +1 -0
- package/dist/{report-6CKSWJNG.js → report-4EVNDHOZ.js} +55 -29
- package/dist/report-4EVNDHOZ.js.map +1 -0
- package/dist/reports/header-image/assets/headline-announcement.png +0 -0
- package/dist/reports/header-image/assets/headline-launch.png +0 -0
- package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
- package/dist/reports/header-image/assets/headline-testing.png +0 -0
- package/dist/reports/header-image/assets/plate-clean.png +0 -0
- package/dist/{self-updating-PPQVV6UA.js → self-updating-6DWI2Z56.js} +9 -9
- package/dist/{selftest-CQ6NOX52.js → selftest-RC36MTKP.js} +25 -20
- package/dist/selftest-RC36MTKP.js.map +1 -0
- package/dist/smoke-B2GQOSIZ.js +20 -0
- package/dist/smoke-B2GQOSIZ.js.map +1 -0
- package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-VYY2LXM2.js} +9 -9
- package/dist/{submissions-GJ6ITBRH.js → submissions-Q7DRFOCD.js} +3 -3
- package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-25J2FO22.js} +9 -9
- package/dist/sync-34HNZZ56.js +44 -0
- package/dist/sync-34HNZZ56.js.map +1 -0
- package/dist/{sync-configs-CJXUX6MD.js → sync-configs-3SE6PSCO.js} +9 -9
- package/dist/{upgrade-WLX27UPC.js → upgrade-WBRQ62FX.js} +9 -9
- package/dist/{webflow-624FEBFD.js → webflow-IRKOIT7S.js} +4 -4
- package/dist/webhook-VOGOW3W6.js +38 -0
- package/dist/webhook-VOGOW3W6.js.map +1 -0
- package/dist/{websites-SADZR4XQ.js → websites-SXWTP72S.js} +6 -2
- package/dist/websites-SXWTP72S.js.map +1 -0
- package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-MBEILLPU.js} +4 -4
- package/dist/write-audits-to-airtable-MBEILLPU.js.map +1 -0
- package/package.json +5 -2
- package/dist/airtable-HRKSA7V4.js +0 -9
- package/dist/announce-GIAYN67S.js.map +0 -1
- package/dist/chunk-37S6L6G4.js.map +0 -1
- package/dist/chunk-3YXDSCM6.js.map +0 -1
- package/dist/chunk-4DHQCN5H.js.map +0 -1
- package/dist/chunk-4SOCPK7A.js +0 -108
- package/dist/chunk-4SOCPK7A.js.map +0 -1
- package/dist/chunk-577HCGZO.js.map +0 -1
- package/dist/chunk-6QRLRT3P.js.map +0 -1
- package/dist/chunk-COZPL62X.js.map +0 -1
- package/dist/chunk-D4FVAIEJ.js.map +0 -1
- package/dist/chunk-DTTIFYAM.js.map +0 -1
- package/dist/chunk-MGR2H7F4.js.map +0 -1
- package/dist/chunk-MPBXOJ3N.js.map +0 -1
- package/dist/chunk-NWB4O5EY.js.map +0 -1
- package/dist/chunk-SEEA6RXF.js.map +0 -1
- package/dist/chunk-U5SZ4FOP.js +0 -26
- package/dist/chunk-U5SZ4FOP.js.map +0 -1
- package/dist/client-X5WUJWDE.js +0 -11
- package/dist/db-JSIZYT23.js +0 -23
- package/dist/db-JSIZYT23.js.map +0 -1
- package/dist/ensure-site-S25MDDHW.js.map +0 -1
- package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
- package/dist/github-signals-G4ILZAJ6.js.map +0 -1
- package/dist/header-image-T7XD37JL.js.map +0 -1
- package/dist/migrate-LJUFGXQZ.js +0 -7
- package/dist/orchestrate-KDNXZQX7.js +0 -27
- package/dist/report-6CKSWJNG.js.map +0 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/selftest-CQ6NOX52.js.map +0 -1
- /package/dist/{airtable-HRKSA7V4.js.map → airtable-G7WBQQMI.js.map} +0 -0
- /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-EC74CPLS.js.map} +0 -0
- /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
- /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
- /package/dist/{chunk-Q276MZ7E.js.map → chunk-3VQWSE3F.js.map} +0 -0
- /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
- /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
- /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
- /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
- /package/dist/{chunk-TP5M2NQB.js.map → chunk-F5KM4LEQ.js.map} +0 -0
- /package/dist/{chunk-62VF32IC.js.map → chunk-ILUNZHYV.js.map} +0 -0
- /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
- /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
- /package/dist/{chunk-PBWDDDQH.js.map → chunk-KTH5ZODA.js.map} +0 -0
- /package/dist/{chunk-7J53SUT6.js.map → chunk-P2FE5AI6.js.map} +0 -0
- /package/dist/{chunk-GZNBCHAZ.js.map → chunk-TERE5SDF.js.map} +0 -0
- /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
- /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
- /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
- /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
- /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
- /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
- /package/dist/{chunk-WYYQK4D3.js.map → chunk-ZKXSX4LO.js.map} +0 -0
- /package/dist/{client-X5WUJWDE.js.map → client-AUE3XUPO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-6L6P74WR.js.map} +0 -0
- /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-WJC2UMYS.js.map} +0 -0
- /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → import-airtable-HE43FB4H.js.map} +0 -0
- /package/dist/{migrate-LJUFGXQZ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
- /package/dist/{orchestrate-KDNXZQX7.js.map → lighthouse-airtable-LBVIYYP5.js.map} +0 -0
- /package/dist/{websites-SADZR4XQ.js.map → migrate-GQH237A6.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → notify-C645PGD5.js.map} +0 -0
- /package/dist/{onboard-53CITIM7.js.map → onboard-GK4PHPNI.js.map} +0 -0
- /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-3RHOZUUT.js.map} +0 -0
- /package/dist/{prismic-models-PVES6R5V.js.map → prismic-models-QBGUT2Q3.js.map} +0 -0
- /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
- /package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-QJY75ODR.js.map} +0 -0
- /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-6DWI2Z56.js.map} +0 -0
- /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-VYY2LXM2.js.map} +0 -0
- /package/dist/{submissions-GJ6ITBRH.js.map → submissions-Q7DRFOCD.js.map} +0 -0
- /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-25J2FO22.js.map} +0 -0
- /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-3SE6PSCO.js.map} +0 -0
- /package/dist/{upgrade-WLX27UPC.js.map → upgrade-WBRQ62FX.js.map} +0 -0
- /package/dist/{webflow-624FEBFD.js.map → webflow-IRKOIT7S.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/forms/payload.ts","../src/forms/ingest.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { SubmissionMeta } from \"./meta.js\";\n\n/** The JSON wire format a fleet site forwards to the ingest endpoint.\n *\n * Underscore-prefixed keys are RESERVED for transport concerns and are never\n * lead data: `_meta` (below) and `_screenOut` (the bot screen-out beacon —\n * a `{ _screenOut: \"honeypot\" | \"too-fast\" }` body is a counter ping, not a\n * submission; see ingest.ts `parseScreenOut`). Site field names must not\n * start with `_`. */\nexport type SubmissionPayload = {\n formType?: string;\n name?: string;\n firstName?: string;\n lastName?: string;\n email?: string;\n phone?: string;\n message?: string;\n sourceUrl?: string;\n utm?: string;\n /** Any additional, site-specific fields. */\n extra?: Record<string, unknown>;\n /** Reserved transport envelope (token/IP/UA); stripped by normalizeSubmission, never persisted. */\n _meta?: SubmissionMeta;\n};\n\nexport type NormalizedSubmission = {\n formType: FormType;\n name: string;\n email: string;\n phone?: string;\n message?: string;\n sourceUrl?: string;\n utm?: string;\n extraFields: Record<string, unknown>;\n};\n\nexport type NormalizeResult =\n { ok: true; value: NormalizedSubmission } | { ok: false; errors: string[] };\n\nconst KNOWN_KEYS = new Set([\n \"formType\",\n \"name\",\n \"firstName\",\n \"lastName\",\n \"email\",\n \"phone\",\n \"message\",\n \"sourceUrl\",\n \"utm\",\n \"extra\",\n \"_meta\",\n]);\n\n// Keys that, copied as own-properties into the captured object, could surprise a\n// downstream consumer (template/DB layer). This is an untrusted boundary, so the\n// extraFields merge drops them rather than trusting the caller.\nconst DANGEROUS_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\nconst EMAIL_RE = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v.trim() : \"\";\n}\n\n/**\n * Resolve the submission's formType. An ABSENT/blank value defaults to \"contact\"\n * (the long-standing minimal-form contract). A PRESENT but unrecognized value\n * (e.g. a typo'd \"news\") returns null → the caller rejects it, rather than\n * silently storing it as contact — which would, for a newsletter signup, drop the\n * Mailchimp fan-out. Mirrors createIngestEndpoint's reject-invalid behavior.\n */\nfunction resolveFormType(raw: unknown): FormType | null {\n const s = str(raw);\n if (s === \"\") return \"contact\";\n return (SUBMISSION_FORM_TYPES as readonly string[]).includes(s) ? (s as FormType) : null;\n}\n\n/**\n * Defensively normalize an untrusted ingest payload into typed fields. Folds\n * name/first+last, lowercases email, and dumps every unclaimed key into\n * extraFields so no site-specific data is lost. Rejects only when there's\n * nothing to act on (no email AND no message) or a present email is malformed.\n */\nexport function normalizeSubmission(payload: unknown): NormalizeResult {\n if (typeof payload !== \"object\" || payload === null) {\n return { ok: false, errors: [\"payload must be a JSON object\"] };\n }\n const p = payload as Record<string, unknown>;\n const name = str(p.name) || [str(p.firstName), str(p.lastName)].filter(Boolean).join(\" \");\n const email = str(p.email).toLowerCase();\n const message = str(p.message);\n\n const formType = resolveFormType(p.formType);\n const errors: string[] = [];\n if (!email && !message) errors.push(\"at least one of email or message is required\");\n if (email && !EMAIL_RE.test(email)) errors.push(\"email is not a valid address\");\n if (formType === null) errors.push(\"formType is not a recognized form type\");\n // The `|| formType === null` is redundant for control flow (the push above\n // already made errors non-empty) but narrows formType to FormType past this line.\n if (errors.length > 0 || formType === null) return { ok: false, errors };\n\n const extraFields: Record<string, unknown> = {};\n const extra = p.extra;\n if (typeof extra === \"object\" && extra !== null) {\n for (const [k, v] of Object.entries(extra)) {\n if (!DANGEROUS_KEYS.has(k)) extraFields[k] = v;\n }\n }\n for (const [k, v] of Object.entries(p)) {\n if (!KNOWN_KEYS.has(k) && !DANGEROUS_KEYS.has(k)) extraFields[k] = v;\n }\n\n const value: NormalizedSubmission = {\n formType,\n name,\n email,\n extraFields,\n };\n const phone = str(p.phone);\n if (phone) value.phone = phone;\n if (message) value.message = message;\n const sourceUrl = str(p.sourceUrl);\n if (sourceUrl) value.sourceUrl = sourceUrl;\n const utm = str(p.utm);\n if (utm) value.utm = utm;\n return { ok: true, value };\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type {\n SubmissionRow,\n SubmissionInput,\n NotifyStatus,\n SubmissionStatus,\n} from \"../reports/submission-row.js\";\nimport { normalizeSubmission, type NormalizedSubmission } from \"./payload.js\";\nimport type { TurnstileOutcome, TurnstileVerification } from \"./turnstile.js\";\nimport { SPAM_THRESHOLD, type SpamVerdict } from \"./spam-classifier.js\";\n\n/** How far back the duplicate-spray AND repeat-sender lookups scan. Sprays arrive in\n * bursts but bots also re-run for weeks; 30 days catches repeats without an unbounded\n * table scan. */\nconst DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport type IngestDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n createSubmission: (input: SubmissionInput) => Promise<SubmissionRow>;\n /** Optional last-resort capture for a lead whose site lookup THREW (a store\n * outage, not an unknown slug). When present, the lead is persisted here and\n * the visitor still gets \"accepted\"; `db replay-deadletters` re-runs it once\n * the lookup recovers. Absent → the error propagates exactly as before, so\n * callers that never wired it are byte-for-byte unchanged. */\n deadLetter?: (input: {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n }) => Promise<{ id: string }>;\n notify: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ status: NotifyStatus; messageId: string | null }>;\n stampNotified: (id: string, status: NotifyStatus, messageId: string | null) => Promise<void>;\n now: () => Date;\n /** Optional spam classifier. When present, its verdict drives the stored\n * status/score/reason; absent → every submission scores 0 (fail-open, clean).\n * Injected so the classifier stays a pure, transport-agnostic leaf. */\n classifySpam?: (n: NormalizedSubmission, turnstile: TurnstileOutcome) => SpamVerdict;\n /** Optional: fleet-wide duplicate/near-duplicate body lookup since `since`. Drives\n * the duplicate-spray signal — the same pitch blasted to many sites shows up as\n * identical (`exact`) or template-substituted (`similar`, token-set Jaccard) bodies.\n * Statuses let the retro re-bucket pick still-'new' prior copies; siteId+email let\n * the caller exempt a genuine same-sender resubmission on the same site. Absent →\n * the check is skipped (fail-open clean). */\n findRecentDuplicates?: (\n message: string,\n since: Date,\n ) => Promise<{\n exact: Array<{ id: string; status: string; siteId: string; email: string }>;\n similar: Array<{ id: string; status: string; siteId: string; email: string }>;\n }>;\n /** Optional: recent non-newsletter submissions from the same email since `since`.\n * Drives the cross-site repeat-sender signal — the fleet's sites are unrelated\n * businesses, so one address contacting 2+ of them within the window is a\n * solicitation tell. Absent → the check is skipped (fail-open clean). */\n listRecentSubmissionsForEmail?: (\n email: string,\n since: Date,\n ) => Promise<Array<{ id: string; siteId: string; status: string }>>;\n /** Optional: retroactively re-bucket prior still-'new' rows once a later copy\n * identifies a spray (the first copy is delivered by design). Implementations\n * must only touch status='new' rows. Best-effort — failures are swallowed. */\n retroBucket?: (ids: string[], reason: string) => Promise<void>;\n /** Optional: POST a newsletter submission to the site's configured webhook\n * (best-effort). Omitted in tests/handlers that don't need it. */\n forwardNewsletter?: (\n webhookUrl: string,\n submission: SubmissionRow,\n site: WebsiteRow,\n ) => Promise<{ ok: boolean; status: number }>;\n /** Optional: add a newsletter submitter to the site's Mailchimp audience\n * (best-effort). Omitted where unused. `tagged:false` reports a member who was\n * upserted but could NOT be tagged — recorded separately, see FANOUT below. */\n addToMailchimp?: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ ok: boolean; status: number; tagged?: boolean }>;\n /** Optional: persist the newsletter fan-out outcome on the row (see FANOUT below).\n * Best-effort — failures are swallowed. Absent → the fan-out still runs, just\n * unrecorded (older callers and most tests). */\n stampFanout?: (id: string, fanoutStatus: string) => Promise<void>;\n /** Optional: run the best-effort tail (notify → stamp → fan-out) AFTER the\n * response instead of before it. Production passes Netlify's\n * `context.waitUntil`, which keeps the invocation alive until the promise\n * settles. Absent → the tail runs inline, exactly as it always did; this is a\n * latency change, never a behavioural one.\n *\n * Why it matters: the submitting site waits on this response under an abort\n * budget (forms/client.ts `INGEST_TIMEOUT_MS`). Once the row is written the\n * lead is captured, so every further second spent on email/webhook providers\n * is time in which a captured lead can still be reported to the visitor as a\n * failed submission — which is exactly what happened to 1836dig on\n * 2026-08-03. */\n defer?: (work: Promise<unknown>) => void;\n};\n\nexport type IngestResult =\n | { status: \"accepted\"; submissionId: string; notifyStatus: NotifyStatus | \"deferred\" }\n | { status: \"rejected\"; reason: \"invalid-payload\"; errors: string[] }\n | { status: \"unknown-site\"; slug: string };\n\nexport type ScreenOutDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n recordScreenOut: (siteId: string, reason: \"honeypot\" | \"too-fast\") => Promise<void>;\n};\n\nexport type ScreenOutResult =\n { status: \"recorded\"; slug: string } | { status: \"unknown-site\"; slug: string };\n\n/** Extract the screen-out reason from a beacon body, or null if it isn't one.\n * The beacon key is the reserved `_screenOut` (underscore-namespaced like `_meta`,\n * see payload.ts). The bare `screenOut` key is the DEPRECATED pre-namespacing wire\n * shape — sites run older package versions for a while, so the central receiver\n * keeps accepting it. `_screenOut` wins when both are present. */\nexport function parseScreenOut(payload: unknown): \"honeypot\" | \"too-fast\" | null {\n if (!payload || typeof payload !== \"object\") return null;\n const body = payload as Record<string, unknown>;\n const v = \"_screenOut\" in body ? body[\"_screenOut\"] : body[\"screenOut\"];\n return v === \"honeypot\" || v === \"too-fast\" ? v : null;\n}\n\n/** Resolve the site and record a caught screen-out. Best-effort: a record failure is\n * the caller's to swallow — a missed count must never error a screened bot. */\nexport async function ingestScreenOut(\n deps: ScreenOutDeps,\n slug: string,\n reason: \"honeypot\" | \"too-fast\",\n): Promise<ScreenOutResult> {\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n await deps.recordScreenOut(site.id, reason);\n return { status: \"recorded\", slug };\n}\n\n/**\n * Normalize → resolve site → persist → notify → stamp. The order is load-bearing:\n * the row is written BEFORE notify, and notify/stamp failures are swallowed (logged)\n * so a Resend or Airtable-write-back hiccup can never turn an accepted lead into a 502.\n */\nexport async function ingestSubmission(\n deps: IngestDeps,\n slug: string,\n rawPayload: unknown,\n turnstileInput: TurnstileOutcome | TurnstileVerification = \"unverifiable\",\n): Promise<IngestResult> {\n // Accept either the full verification (the production handler) or a bare outcome\n // string (older callers and virtually every test) — a string simply has no solved-\n // hostname to check. Normalized once so the body has a single shape to reason about.\n const verification: TurnstileVerification =\n typeof turnstileInput === \"string\"\n ? { outcome: turnstileInput, hostname: null }\n : turnstileInput;\n const turnstile = verification.outcome;\n const normalized = normalizeSubmission(rawPayload);\n if (!normalized.ok) {\n return { status: \"rejected\", reason: \"invalid-payload\", errors: normalized.errors };\n }\n // Persist before enrich (#539 Phase 0). The lookup THROWING is a store outage,\n // not an answer — and until 2026-08-23 it was the one await that could cost a\n // lead: the row write comes later, so a thrown lookup 502'd the visitor with\n // nothing recorded anywhere (the 2026-08-17 Airtable quota outage did exactly\n // that, while the submissions store itself was healthy the whole time). With\n // `deadLetter` wired, the lead lands there and the visitor gets an honest\n // \"accepted\"; `db replay-deadletters` runs it through this same function once\n // the lookup recovers, producing a normal row with real classification+notify.\n //\n // Three deliberate boundaries:\n // - A lookup that RESOLVES to null is still `unknown-site` — the store answered,\n // and a junk slug is a rejection, not a lead to save.\n // - A testMode probe rethrows: it persists nothing by design, so there is\n // nothing to save, and swallowing the outage would green the form-e2e audit\n // precisely when central ingest is degraded.\n // - `deadLetter` itself throwing propagates: both stores are down, and the 502\n // is honest — there is nowhere left to put the lead.\n let site: WebsiteRow | null;\n try {\n site = await deps.getWebsiteBySlug(slug);\n } catch (err) {\n if (!deps.deadLetter || isTestMode(rawPayload)) throw err;\n const dl = await deps.deadLetter({\n siteSlug: slug,\n payload: rawPayload,\n turnstile: verification,\n error: String(err),\n receivedAt: deps.now(),\n });\n console.error(\n `[ingest] site lookup failed for '${slug}' — lead dead-lettered as ${dl.id}: ${String(err)}`,\n );\n return { status: \"accepted\", submissionId: dl.id, notifyStatus: \"skipped\" };\n }\n if (!site) return { status: \"unknown-site\", slug };\n\n // Synthetic end-to-end probe (the `form-e2e` fleet audit). A central-only marker\n // on the payload routes the submission away from EVERY real sink: no row is\n // persisted, no spam classification, no operator/autoresponder email, no\n // newsletter fan-out — and Turnstile enforcement is bypassed (the short-circuit\n // sits before that check). The marker therefore grants a bot NO benefit: it\n // reaches no inbox/DB/webhook, so skipping Turnstile costs nothing. This\n // suppression MUST be central — the submitting site alone cannot stop the real\n // inbox firing. Validity + site resolution are still enforced above (a junk body\n // is rejected, an unknown slug is unknown-site), so the marker can't smuggle\n // anything through. Return accepted+skipped so the probe asserts success.\n if (isTestMode(rawPayload)) {\n return { status: \"accepted\", submissionId: \"test-mode\", notifyStatus: \"skipped\" };\n }\n\n const n = normalized.value;\n\n // A site still under construction is where the operator tests their OWN forms:\n // one address, several unrelated sites, minutes apart. That is byte-for-byte the\n // cross-site repeat-sender signature below, so a site in development reliably\n // auto-spams its own builder's test submissions — the row lands `spam_auto`,\n // notify is skipped and the cockpit hides it, which reads exactly like \"the form\n // is broken\" (it did, on 2026-08-17). Spam handling on a site with no real\n // visitors protects nothing, so skip ALL of it: content scoring, the Turnstile\n // escalation, the repeat-sender scan and the duplicate/spray scan — including\n // their retroactive re-bucketing of OTHER sites' rows, which a test submission\n // has no business triggering. Re-enabled the moment the site leaves development.\n const spamHandlingEnabled = site.status !== \"building\";\n\n // Fold the content signals + the Turnstile verdict into ONE spam decision.\n // Absent classifier → treat as clean (fail-open). A throwing classifier is\n // swallowed the same way — a bug in the heuristic must never turn an\n // otherwise-good lead into a 500; it just scores clean. On a `requireTurnstile`\n // site, both an ACTUAL \"fail\" (forged token) AND an \"absent\" token escalate to\n // auto-spam regardless of score — a real browser that renders the widget ALWAYS\n // sends a token, so a completely missing one is the direct-POST-bot signature. A\n // present-but-\"unverifiable\" token (expired/duplicate — a real browser DID render\n // the widget) stays neutral, as does anything on a site that hasn't opted in.\n let verdict: SpamVerdict = { score: 0, reasons: [] };\n if (spamHandlingEnabled && deps.classifySpam) {\n try {\n verdict = deps.classifySpam(n, turnstile);\n } catch (err) {\n console.error(`[ingest] classifySpam threw: ${String(err)}`);\n }\n }\n const reasons = [...verdict.reasons];\n let status: SubmissionStatus = verdict.score >= SPAM_THRESHOLD ? \"spam_auto\" : \"new\";\n if (\n spamHandlingEnabled &&\n site.requireTurnstile &&\n (turnstile === \"fail\" || turnstile === \"absent\")\n ) {\n status = \"spam_auto\";\n const reason = turnstile === \"fail\" ? \"turnstile-required-failed\" : \"turnstile-required-absent\";\n if (!reasons.includes(reason)) reasons.push(reason);\n } else if (\n spamHandlingEnabled &&\n site.requireTurnstile &&\n turnstile === \"pass\" &&\n verification.hostname !== null &&\n !turnstileHostnameAcceptable(verification.hostname, site.url)\n ) {\n // Defense-in-depth vs token farming: the token PASSED, but Cloudflare says it was\n // solved on a host unrelated to this site. Cloudflare domain-binds sitekeys, so\n // this only trips on a loose widget allowlist — still, a passing token from a\n // foreign host accompanying a gated site's submission is not a real visitor. A\n // null hostname (older responses) or an unparseable site.url skips the check\n // entirely (fail-open); subdomains of the site's host (www., previews) match.\n status = \"spam_auto\";\n if (!reasons.includes(\"turnstile-required-hostname\"))\n reasons.push(\"turnstile-required-hostname\");\n }\n\n // Cross-site repeat-sender signal: the fleet's sites are UNRELATED businesses\n // (art gallery, realtor, home builder…), so the same email writing to 2+ different\n // sites within the window is a solicitation tell no single-message scan can see.\n // Same-site repeats alone must NOT trigger — those are genuine follow-ups. The\n // operator explicitly accepts overblocking here (spam_auto is recoverable). Prior\n // still-'new' rows on OTHER sites are retro-bucketed too: the first copy of a\n // spray is delivered by design, so the copy that identifies it also cleans the\n // queue. The scan runs EVEN WHEN the incoming copy is already spam_auto — after the\n // classifier began catching whole spray families, an already-bucketed copy was\n // skipping this scan and the retro cleanup never fired for exactly the sprays it\n // was built for; escalation/reason still only applies when not already spam.\n // Best-effort — a lookup failure never blocks a lead.\n if (spamHandlingEnabled && n.formType !== \"newsletter\" && deps.listRecentSubmissionsForEmail) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const prior = await deps.listRecentSubmissionsForEmail(n.email, since);\n const otherSites = prior.filter((p) => p.siteId !== site.id);\n if (otherSites.length > 0) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(\"repeat-sender\")) reasons.push(\"repeat-sender\");\n }\n const retroIds = otherSites.filter((p) => p.status === \"new\").map((p) => p.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:repeat-sender\");\n } catch (err) {\n console.error(`[ingest] retroBucket (repeat-sender) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] listRecentSubmissionsForEmail threw: ${String(err)}`);\n }\n }\n\n // Duplicate/spray signal: the same pitch blasted across the fleet (or repeated) is\n // a bot tell a lone content scan can't see. An identical body ('exact') OR a\n // template-substituted near-copy ('similar' — the live dog-harness spray differed\n // only in greeting; SEO sprays swap the target domain per site) → auto-spam\n // (recoverable). Prior still-'new' copies are retro-bucketed for the same reason\n // as above, and the scan runs even when the incoming copy is already spam_auto\n // (see the repeat-sender note). GENUINE-RESUBMIT EXEMPTION: a match from the SAME\n // sender on the SAME site is a real visitor double-submitting or resending after\n // silence, not spray evidence — without this exemption the resend was silently\n // bucketed AND retro-flipped the delivered original, vanishing an active lead\n // with no signal. Only cross-site or different-sender copies count. Guarded:\n // non-newsletter forms with a real body; the db helper ignores short bodies /\n // small token sets. Best-effort — a lookup failure never blocks a lead.\n if (\n spamHandlingEnabled &&\n n.formType !== \"newsletter\" &&\n n.message !== undefined &&\n deps.findRecentDuplicates\n ) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const dupes = await deps.findRecentDuplicates(n.message, since);\n const senderEmail = n.email.trim().toLowerCase();\n const isOwnResend = (m: { siteId: string; email: string }) =>\n m.siteId === site.id && m.email.trim().toLowerCase() === senderEmail;\n const exact = dupes.exact.filter((m) => !isOwnResend(m));\n const similar = dupes.similar.filter((m) => !isOwnResend(m));\n const reason =\n exact.length > 0 ? \"duplicate-body\" : similar.length > 0 ? \"similar-body\" : null;\n if (reason !== null) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(reason)) reasons.push(reason);\n }\n const retroIds = [...exact, ...similar].filter((m) => m.status === \"new\").map((m) => m.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:duplicate-body\");\n } catch (err) {\n console.error(`[ingest] retroBucket (duplicate-body) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] findRecentDuplicates threw: ${String(err)}`);\n }\n }\n const spamReason = reasons.length > 0 ? reasons.join(\",\") : null;\n\n const row = await deps.createSubmission({\n siteId: site.id,\n formType: n.formType,\n name: n.name,\n email: n.email,\n extraFields: n.extraFields,\n status,\n spamScore: verdict.score,\n spamReason,\n // Optional fields spread only when present — exactOptionalPropertyTypes\n // forbids assigning `undefined` to an optional `phone?: string` etc.\n ...(n.phone !== undefined ? { phone: n.phone } : {}),\n ...(n.message !== undefined ? { message: n.message } : {}),\n ...(n.sourceUrl !== undefined ? { sourceUrl: n.sourceUrl } : {}),\n ...(n.utm !== undefined ? { utm: n.utm } : {}),\n submittedAt: deps.now(),\n });\n\n // Auto-spam (and operator-marked spam) is captured but silent: no operator\n // email, no autoresponder, no newsletter fan-out. Skip notify entirely and\n // record the honest \"skipped\" stamp. notify.ts also nulls both builders for\n // these statuses (defense in depth), but short-circuiting here means the\n // injected notify dep is never even invoked for a spam row.\n const isSpam = row.status === \"spam_auto\" || row.status === \"spam\";\n\n // ── best-effort tail ───────────────────────────────────────────────────────\n // notify → stamp → newsletter fan-out. NOTHING below can cost the lead: the\n // row is already durable, and every step here is swallowed+logged rather than\n // allowed to turn an accepted lead into a 502. That is precisely why it does\n // not belong on the visitor's critical path — the submitting site waits on\n // this response under an abort budget (forms/client.ts INGEST_TIMEOUT_MS), so\n // Resend latency used to be able to make a captured lead read as a failed\n // submission in the visitor's browser (1836dig, 2026-08-03).\n //\n // `deps.defer` (Netlify's `context.waitUntil` in production) runs the tail\n // AFTER the response goes out. Absent — every test, and any runtime without\n // post-response execution — it runs inline exactly as before, so this is a\n // latency change and never a behavioural one.\n const runTail = async (): Promise<NotifyStatus> => {\n let notify: { status: NotifyStatus; messageId: string | null };\n if (isSpam) {\n notify = { status: \"skipped\", messageId: null };\n } else {\n try {\n notify = await deps.notify(site, row);\n } catch (err) {\n console.error(`[ingest] notify threw: ${String(err)}`);\n notify = { status: \"failed\", messageId: null };\n }\n }\n try {\n await deps.stampNotified(row.id, notify.status, notify.messageId);\n } catch (err) {\n console.error(`[ingest] stampNotified failed: ${String(err)}`);\n }\n\n await runFanout();\n return notify.status;\n };\n\n // Newsletter fan-out: each configured destination fires best-effort and is\n // swallowed+logged — the lead is already persisted; never turn it into a 502.\n // Guarded on the row status so a spam signup is never forwarded to a site\n // webhook or added to a Mailchimp audience.\n //\n // FANOUT provenance (2026-07-31): the outcome is ALSO recorded on the row, one\n // `<destination>:<outcome>` token per attempt, comma-joined in attempt order —\n // `webhook:ok,mailchimp:401`. Outcome is `ok`, the HTTP status of a non-2xx\n // (`0` for a network error / misconfiguration, see mailchimp.ts), or `threw`.\n // `mailchimp-tags:failed` is appended when the member was upserted but the tag\n // write did not land. Until this existed the results were console.error-only, so\n // an expired Mailchimp key stopped signups reaching the audience while the row\n // still read `notify=sent` — healthy-looking, with the failure visible nowhere the\n // operator ever looks. Nothing attempted → nothing stamped (null stays \"no\n // destination configured\", NOT \"we tried and lost the answer\"). The stamp is\n // best-effort like everything else here: a provenance write must never cost a lead.\n // A const arrow, not a hoisted declaration: `site` is narrowed non-null above,\n // and that narrowing only survives into a closure created after it. Declared\n // below runTail (which calls it) but before either call site, so there is no TDZ.\n const runFanout = async (): Promise<void> => {\n const fanout: string[] = [];\n if (n.formType === \"newsletter\" && !isSpam) {\n if (site.newsletterWebhook && deps.forwardNewsletter) {\n try {\n const fwd = await deps.forwardNewsletter(site.newsletterWebhook, row, site);\n if (!fwd.ok)\n console.error(`[ingest] newsletter webhook → ${fwd.status} for ${site.name}`);\n fanout.push(fwd.ok ? \"webhook:ok\" : `webhook:${fwd.status}`);\n } catch (err) {\n console.error(`[ingest] newsletter webhook threw: ${String(err)}`);\n fanout.push(\"webhook:threw\");\n }\n }\n if (site.mailchimpApiKey && site.mailchimpAudienceId && deps.addToMailchimp) {\n try {\n const mc = await deps.addToMailchimp(site, row);\n if (!mc.ok) console.error(`[ingest] mailchimp add → ${mc.status} for ${site.name}`);\n fanout.push(mc.ok ? \"mailchimp:ok\" : `mailchimp:${mc.status}`);\n // Only meaningful alongside a successful add — a failed add was never tagged\n // either, and one token per real problem keeps the row readable.\n if (mc.ok && mc.tagged === false) {\n console.error(`[ingest] mailchimp tags not applied for ${site.name}`);\n fanout.push(\"mailchimp-tags:failed\");\n }\n } catch (err) {\n console.error(`[ingest] mailchimp add threw: ${String(err)}`);\n fanout.push(\"mailchimp:threw\");\n }\n }\n }\n if (fanout.length > 0 && deps.stampFanout) {\n try {\n await deps.stampFanout(row.id, fanout.join(\",\"));\n } catch (err) {\n console.error(`[ingest] stampFanout failed: ${String(err)}`);\n }\n }\n };\n\n if (deps.defer) {\n // A rejection escaping here would land as an unhandled rejection in the\n // platform's post-response context, where nothing is left to catch it — so\n // the boundary is made unrejectable. Everything inside is already swallowed;\n // this guards the shape, not a known throw.\n deps.defer(\n runTail().catch((err) => {\n console.error(`[ingest] deferred tail threw: ${String(err)}`);\n }),\n );\n // The lead is durable and the response can go out now. The real notify\n // outcome lands on the ROW via stampNotified moments later — this field is\n // only ever read by callers wanting the in-request outcome, which by\n // definition does not exist once the tail is deferred.\n return { status: \"accepted\", submissionId: row.id, notifyStatus: \"deferred\" };\n }\n return { status: \"accepted\", submissionId: row.id, notifyStatus: await runTail() };\n}\n\n/** True when `a` and `b` are the same host or one is a subdomain of the other\n * (case-insensitive): `www.reddoorla.com` vs `reddoorla.com` matches both ways.\n * Exported for tests. PURE. */\nexport function hostsMatch(a: string, b: string): boolean {\n const ha = a.trim().toLowerCase();\n const hb = b.trim().toLowerCase();\n if (ha.length === 0 || hb.length === 0) return false;\n return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);\n}\n\n/** Whether a passing token's solved-hostname is acceptable for the site at `siteUrl`.\n * An unparseable/hostless `siteUrl` returns TRUE — the check self-disables rather\n * than punishing a possibly-real visitor for an operator data problem (fail-open,\n * same philosophy as verifyTurnstile). PURE. */\nexport function turnstileHostnameAcceptable(tokenHostname: string, siteUrl: string): boolean {\n let siteHost: string;\n try {\n siteHost = new URL(siteUrl).hostname;\n } catch {\n return true;\n }\n if (!siteHost) return true;\n return hostsMatch(tokenHostname, siteHost);\n}\n\n/** True when an untrusted ingest payload carries the synthetic-probe marker\n * (top-level `testMode: true`). Read from the RAW payload so the branch never\n * depends on normalization internals; any non-`true` value is ignored (a real\n * visitor's form never sets it — the starter only forwards it when the submitted\n * form field `testMode` equals \"true\"). */\nexport function isTestMode(rawPayload: unknown): boolean {\n if (!rawPayload || typeof rawPayload !== \"object\") return false;\n return (rawPayload as Record<string, unknown>).testMode === true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
} from "./chunk-R53VL3RI.js";
|
|
5
5
|
import {
|
|
6
6
|
withRecipe
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-3G25KIWW.js";
|
|
8
8
|
import {
|
|
9
9
|
defaultSpawn
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-LBYOLBW7.js";
|
|
11
11
|
|
|
12
12
|
// src/recipes/health-endpoint/index.ts
|
|
13
13
|
import { access, mkdir, writeFile } from "fs/promises";
|
|
@@ -142,4 +142,4 @@ async function healthEndpoint(site, deps = { spawn: defaultSpawn }) {
|
|
|
142
142
|
export {
|
|
143
143
|
healthEndpoint
|
|
144
144
|
};
|
|
145
|
-
//# sourceMappingURL=chunk-
|
|
145
|
+
//# sourceMappingURL=chunk-BQRFQSGY.js.map
|
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
import {
|
|
2
2
|
queueDraft
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-ILUNZHYV.js";
|
|
4
4
|
import {
|
|
5
5
|
generateHeaderImage
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-5UINJNAJ.js";
|
|
7
7
|
import {
|
|
8
|
-
checklistFor,
|
|
9
|
-
createDraft,
|
|
10
|
-
gatingFields,
|
|
11
|
-
listReportsForSite,
|
|
12
8
|
renderReportHtml,
|
|
13
9
|
resolveCopy
|
|
14
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-CQR4LIM7.js";
|
|
15
11
|
import {
|
|
16
12
|
uploadAttachment
|
|
17
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-Z7AXYKQB.js";
|
|
14
|
+
import {
|
|
15
|
+
createDraft,
|
|
16
|
+
listReportsForSite
|
|
17
|
+
} from "./chunk-ACBRLLFT.js";
|
|
18
|
+
import {
|
|
19
|
+
checklistFor,
|
|
20
|
+
gatingFields
|
|
21
|
+
} from "./chunk-VAD43MOR.js";
|
|
18
22
|
import {
|
|
19
23
|
isNetlifyAppUrl
|
|
20
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-EVBCK2KC.js";
|
|
21
25
|
import {
|
|
22
26
|
siteSlug,
|
|
23
27
|
updateAnalyticsHealth
|
|
24
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-LDWZSLSU.js";
|
|
25
29
|
import {
|
|
26
30
|
defaultCredentialsPath
|
|
27
31
|
} from "./chunk-6VAL7XJT.js";
|
|
@@ -492,7 +496,9 @@ async function refreshHeaderImage(site, deps = {}) {
|
|
|
492
496
|
const upload = deps.upload ?? uploadAttachment;
|
|
493
497
|
try {
|
|
494
498
|
const gen = await generate({ url: site.url, slug: siteSlug(site.name) });
|
|
495
|
-
await upload(site.id, "Header image", gen.bytes, gen.filename, gen.contentType
|
|
499
|
+
await upload(site.id, "Header image", gen.bytes, gen.filename, gen.contentType, {
|
|
500
|
+
replaceIn: "Websites"
|
|
501
|
+
});
|
|
496
502
|
return true;
|
|
497
503
|
} catch (err) {
|
|
498
504
|
console.warn(
|
|
@@ -724,8 +730,9 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
|
724
730
|
|
|
725
731
|
export {
|
|
726
732
|
readGaConfig,
|
|
733
|
+
refreshHeaderImage,
|
|
727
734
|
draftReportForSite,
|
|
728
735
|
fetchGaUsers,
|
|
729
736
|
fetchSearch
|
|
730
737
|
};
|
|
731
|
-
//# sourceMappingURL=chunk-
|
|
738
|
+
//# sourceMappingURL=chunk-BYK66EWO.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/ga/config.ts","../src/reports/draft.ts","../src/reports/auto-tick.ts","../src/reports/ga/client.ts","../src/reports/ga/failover.ts","../src/reports/search/client.ts"],"sourcesContent":["import { dirname, join } from \"node:path\";\nimport { defaultCredentialsPath } from \"../../util/credentials.js\";\n\nexport type GaConfig = {\n /** Workspace users the service account impersonates (domain-wide delegation), in\n * failover order — the first subject that authenticates wins. */\n subjects: string[];\n /** Absolute path to the service-account JSON key file. */\n keyPath: string;\n};\n\n/**\n * Read GA configuration from the environment (credentials.env is already loaded into\n * process.env by the CLI entrypoint). Returns null when `GA_SUBJECT` is unset/blank — the\n * signal that GA enrichment is simply not configured, so drafting skips it silently.\n *\n * `GA_SUBJECT` is a comma-separated list of impersonation subjects tried in order\n * (e.g. `reports@reddoorla.com,person@reddoorla.com`), so losing one Workspace account\n * degrades to a logged failover instead of blanking the whole fleet's analytics; a single\n * address remains the degenerate one-element case. Entries are trimmed, empties dropped.\n *\n * `GA_SA_KEY_PATH` is optional; it defaults to `ga-service-account.json` alongside the\n * credentials file (e.g. ~/.config/reddoor-maint/), keeping the key out of the repo.\n */\nexport function readGaConfig(): GaConfig | null {\n const subjects = (process.env.GA_SUBJECT ?? \"\")\n .split(\",\")\n .map((s) => s.trim())\n .filter(Boolean);\n if (subjects.length === 0) return null;\n const keyPath =\n process.env.GA_SA_KEY_PATH?.trim() ||\n join(dirname(defaultCredentialsPath()), \"ga-service-account.json\");\n return { subjects, keyPath };\n}\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ReportType, LighthouseScores } from \"./types.js\";\nimport { renderReportHtml } from \"./render.js\";\nimport { siteSlug, updateAnalyticsHealth } from \"./airtable/websites.js\";\nimport { resolveCopy } from \"./copy.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { ReportRow } from \"./airtable/reports.js\";\nimport { createDraft, listReportsForSite } from \"./airtable/reports.js\";\nimport { queueDraft } from \"./queue.js\";\nimport { autoTickChecklist } from \"./auto-tick.js\";\nimport { uploadAttachment } from \"./airtable/attachments.js\";\nimport type { AirtableBase } from \"./airtable/client.js\";\nimport { readGaConfig } from \"./ga/config.js\";\nimport { fetchPeriodUsers } from \"./ga/client.js\";\nimport { fetchSearchPresence } from \"./search/client.js\";\nimport type { SearchPresence } from \"./search/client.js\";\nimport { generateHeaderImage } from \"./header-image/index.js\";\nimport type { GeneratedHeaderImage } from \"./header-image/index.js\";\n\nexport type RefreshHeaderDeps = {\n generate?: (input: { url: string; slug?: string }) => Promise<GeneratedHeaderImage>;\n upload?: (\n recordId: string,\n field: string,\n bytes: Uint8Array,\n filename: string,\n contentType: string,\n opts?: { replaceIn?: string },\n ) => Promise<void>;\n};\n\n/**\n * Regenerate a site's Header image from its live homepage so the report ships a\n * current screenshot rather than one frozen whenever the image was last made by\n * hand. Sonder alone runs 16 reports a year, so a static header goes visibly\n * stale.\n *\n * BEST-EFFORT BY DESIGN — returns false and never throws. A capture or upload\n * failure must not fail the draft: the stored image is still perfectly usable,\n * and the operator reviews the rendered preview before approving the send.\n */\nexport async function refreshHeaderImage(\n site: WebsiteRow,\n deps: RefreshHeaderDeps = {},\n): Promise<boolean> {\n if (!site.url) return false;\n const generate = deps.generate ?? generateHeaderImage;\n const upload = deps.upload ?? uploadAttachment;\n try {\n const gen = await generate({ url: site.url, slug: siteSlug(site.name) });\n await upload(site.id, \"Header image\", gen.bytes, gen.filename, gen.contentType, {\n replaceIn: \"Websites\",\n });\n return true;\n } catch (err) {\n console.warn(\n `⚠ header-image refresh skipped for ${site.name}: ${\n err instanceof Error ? err.message : String(err)\n } — keeping the stored image`,\n );\n return false;\n }\n}\n\nexport type DraftOptions = {\n /** Where to write the local preview HTML when `previewOnly`. Defaults to `reports/<slug>/draft.html`. */\n previewPath?: string;\n /** If true: render locally only, never touch Airtable. */\n previewOnly?: boolean;\n /** Whether to run the GA / Search Console enrichment fetches. Defaults to\n * `base !== null`, i.e. the real drafting path enriches and a preview does not.\n *\n * This exists because `base === null` was carrying two unrelated meanings —\n * \"never write to Airtable\" AND \"perform no IO at all\" — and only the first is\n * what `previewOnly` actually asks for. Enrichment reads Google; it writes\n * nothing, so there is no reason a preview cannot do it on request. Conflating\n * the two made the preview path structurally incapable of ever producing an\n * ANALYTICS section, which in turn made a CI job built to prove the GA secrets\n * fail 100% of the time and report it as a credential outage (2026-08-12). Set\n * this true to render a preview that exercises the credentials for real. */\n enrich?: boolean;\n /** UTC \"YYYY-MM\" recurrence key; falls back to periodEnd's month when omitted. */\n period?: string;\n /** Airtable record id of an EXISTING (not-ready) row to COMPLETE in place rather\n * than creating a new one. When set, we skip createDraft and only re-render →\n * upload the HTML attachment → flip Draft ready on this row. Used by the --due\n * re-draft path to finish a draft whose createDraft succeeded but whose\n * setDraftReady never ran (a crash mid-sequence wedged the period). */\n completeRowId?: string;\n /** The mapped ReportRow being completed, returned as `reportRow` from the\n * complete path so callers keep the same shape they get on the create path. */\n existingRow?: ReportRow;\n /** Injected deps for the draft-time header refresh, or `false` to skip it\n * entirely. Tests set `false` (or a stub) so a unit suite never launches a\n * browser or resolves DNS. Production leaves it unset and gets the real thing. */\n refreshHeader?: RefreshHeaderDeps | false;\n};\n\n/** An enrichment fetch that *errored* (not one that was legitimately skipped\n * because it isn't configured / the site lacks the inputs). Surfaced so a\n * fleet-wide GA/Search outage is visible in a `--due` batch summary instead of\n * hiding behind one easily-missed console.warn per site. */\nexport type SoftFailure = \"ga\" | \"search\";\n\nexport type DraftResult = {\n /** null when previewOnly. */\n reportRow: ReportRow | null;\n /** Path to the local preview file (only set when previewOnly). */\n htmlPath: string | null;\n /** Always present — the rendered HTML string. */\n html: string;\n /** Enrichment fetches that errored for this site (empty on success or skip). */\n softFailures: SoftFailure[];\n /** True when search enrichment fell back to the site name (no explicit `Search query`)\n * AND that default matched nothing in Search Console — the signal that this site needs\n * a hand-tuned brand query. Distinct from a soft-failure/outage: the fetch succeeded, it\n * just found no data for the name. Requires a property to have RESOLVED — a site with no\n * Search Console property at all raises `searchPropertyMissing` instead (a query change\n * can't fix that, and prescribing one would silence the real problem). */\n searchDefaultMissed: boolean;\n /** True when NO Search Console property matched the site for any subject — a\n * missing/unverified property or lost service-account access. Fires for BOTH explicit\n * and name-default queries (unlike `searchDefaultMissed`), because no query can succeed\n * without a property. Distinct from a soft-failure: the API worked, the property list\n * just contained nothing for this site. */\n searchPropertyMissing: boolean;\n /** Whether the draft was placed in the approve queue. False when a higher-or-equal-tier\n * report is already queued for the site (single-queue rule); null on the previewOnly path. */\n queued: boolean | null;\n /** Ids of lower-tier queued reports this draft superseded (un-queued). */\n supersededIds: string[];\n};\n\nfunction scoresFromWebsite(siteRow: WebsiteRow): LighthouseScores {\n const { pScore, rScore, bpScore, seoScore } = siteRow;\n if (pScore === null || rScore === null || bpScore === null || seoScore === null) {\n throw new Error(\n `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). ` +\n `Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`,\n );\n }\n return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };\n}\n\nfunction daysAgo(today: Date, n: number): Date {\n // UTC accessors to stay TZ-consistent with `due.ts` (and avoid landing\n // Airtable's `Period start` on a different calendar day than the operator\n // expects on late-night runs near a month boundary). See morning brief\n // 2026-05-29 (M1) for context.\n const out = new Date(today);\n out.setUTCDate(out.getUTCDate() - n);\n return out;\n}\n\n/**\n * Render and create an Airtable draft for one site.\n *\n * No idempotency guard here — the recurrence guard lives in draftDueReports\n * (cli/commands/report.ts), keyed on reportPeriodKey(dueDate). The manual\n * single-site path intentionally always drafts (an operator asking for a draft\n * gets one). findReportByPeriod (airtable/reports.ts) is the real-Airtable\n * point lookup available to dashboard/digest callers that need the same\n * idempotency guarantee outside the CLI batch loop.\n */\nexport async function draftReportForSite(\n base: AirtableBase | null,\n siteRow: WebsiteRow,\n reportType: ReportType,\n options: DraftOptions = {},\n): Promise<DraftResult> {\n const scores = scoresFromWebsite(siteRow);\n\n const today = new Date();\n const slug = siteSlug(siteRow.name);\n\n const periodStart =\n base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);\n\n const periodEnd = today;\n const completedOn = today;\n // \"Last Tested\" on the Maintenance email is the REAL timestamp of the most recent automated\n // Lighthouse audit — stamped live on the Websites row (`Last lighthouse audit at`) by\n // `audit lighthouse --write-airtable` every time the scores refresh. It is deliberately NOT\n // the `testing day` field: that's the recurrence anchor consumed by due.ts and is hand-set, so\n // it goes stale. Reading the audit timestamp keeps the date current with no manual upkeep.\n const lastTestedDate =\n reportType === \"Maintenance\" && siteRow.lastLighthouseAuditAt\n ? new Date(siteRow.lastLighthouseAuditAt)\n : null;\n\n // GA enrichment. Soft-fail: any GA problem leaves the numbers null so\n // the draft still proceeds (operator fills them manually) — GA is an enhancement, not a\n // gate. Rendered with the fetched numbers so the review HTML matches the Airtable fields.\n // An *error* (vs a legitimate not-configured skip) is recorded in softFailures so the\n // caller can surface a fleet-wide outage in the batch summary.\n // Enrichment is gated on `enrich`, NOT on `base`: reading Google and writing to\n // Airtable are independent, and only the write is what previewOnly forbids.\n const shouldEnrich = options.enrich ?? base !== null;\n const gaResult = shouldEnrich\n ? await fetchGaUsers(siteRow, periodStart, periodEnd)\n : NO_ENRICHMENT;\n const searchResult = shouldEnrich\n ? await fetchSearch(siteRow, periodStart, periodEnd)\n : // No-IO render path: nothing was attempted, so this is not an environment gap\n // (`notConfigured` would wrongly tell the operator to go wire a secret).\n {\n ...NO_ENRICHMENT,\n defaultQueryMissed: false,\n propertyMissing: false,\n notConfigured: false,\n };\n const gaUsers = gaResult.value;\n const search = searchResult.value;\n const softFailures: SoftFailure[] = [\n ...(gaResult.softFailed ? ([\"ga\"] as const) : []),\n ...(searchResult.softFailed ? ([\"search\"] as const) : []),\n ];\n\n // Header-image refresh (real path only). Regenerated BEFORE the render so the\n // preview the operator approves carries the same screenshot the client will\n // receive — the send reads this attachment off the Websites row. Gated on\n // `base !== null` exactly like the GA/Search enrichment above: the no-IO render\n // path (base === null, used for pure rendering and tests) must not launch a\n // browser or write to production Airtable. `refreshHeader: false` is the second\n // gate, for suites that DO pass a fake base and would otherwise pay a real\n // chromium launch per case. Production leaves it unset and gets the real\n // refresh. Best-effort — see refreshHeaderImage.\n if (base !== null && options.refreshHeader !== false) {\n await refreshHeaderImage(siteRow, options.refreshHeader ?? {});\n }\n\n const cidName = `${slug}-header`;\n const { html } = await renderReportHtml({\n siteName: siteRow.name,\n siteUrl: siteRow.url,\n reportType,\n completedOn,\n lighthouse: scores,\n gaUsersCurrent: gaUsers?.current,\n gaUsersPrevious: gaUsers?.previous,\n searchPosition: search?.foundOnPage1 ? (search.position ?? undefined) : undefined,\n lastTestedDate,\n commentary: null,\n copy: resolveCopy(siteRow),\n headerImageCid: cidName,\n });\n\n if (options.previewOnly) {\n const path = options.previewPath ?? `reports/${slug}/draft.html`;\n await mkdir(dirname(path), { recursive: true });\n await writeFile(path, html, \"utf-8\");\n return {\n reportRow: null,\n htmlPath: path,\n html,\n softFailures,\n searchDefaultMissed: searchResult.defaultQueryMissed,\n searchPropertyMissing: searchResult.propertyMissing,\n queued: null,\n supersededIds: [],\n };\n }\n\n if (base === null) throw new Error(\"base required when previewOnly=false\");\n\n // Record this site's GA/Search enrichment health for the per-site analytics-failure\n // signal (cockpit/digest). Only when analytics is configured for THIS site — set the\n // timestamp on a soft-fail, clear it (null) on a clean enrichment so the signal\n // self-heals. Best-effort: the `Analytics soft-fail at` column is operator-added, so\n // until it exists the write throws UNKNOWN_FIELD_NAME — which must NOT break drafting.\n if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {\n try {\n await updateAnalyticsHealth(\n base,\n siteRow.id,\n softFailures.length > 0 ? today.toISOString() : null,\n );\n } catch (e) {\n console.warn(`⚠ analytics-health write skipped for ${siteRow.name}: ${(e as Error).message}`);\n }\n }\n\n // \"Finish an existing row\" path (the --due re-draft wedge fix). When the caller\n // hands us a row that was created but never made Draft-ready — a crash between\n // createDraft and setDraftReady leaves exactly this — we DON'T createDraft again\n // (that would duplicate the period). We re-attach the rendered HTML and queue the\n // EXISTING row, completing the half-made draft in place. The row's other fields\n // (scores, period, dates) were already written at create time; the only pieces a\n // crash drops are the attachment + the ready flag.\n if (options.completeRowId) {\n await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: options.completeRowId,\n siteId: siteRow.id,\n reportType,\n });\n return {\n reportRow: options.existingRow ?? null,\n htmlPath: null,\n html,\n softFailures,\n searchDefaultMissed: searchResult.defaultQueryMissed,\n searchPropertyMissing: searchResult.propertyMissing,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n }\n\n // Auto-tick the checklist boxes the system can prove (Phase 1: Google Indexed via the\n // inline search signal). Fail-safe lives in autoTickChecklist: only `pass` entries are ticked;\n // the evidence snapshot drives the dashboard's green/amber badges. The operator's approve gate\n // and per-box override are unchanged.\n const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });\n const checklistTicks = [...evidence.entries()]\n .filter(([, e]) => e.result === \"pass\")\n .map(([field]) => field);\n const autoEvidence = Object.fromEntries(evidence);\n\n const reportId = `${siteRow.name} — ${reportType} — ${periodEnd.toISOString().slice(0, 10)}`;\n const created = await createDraft(base, {\n reportId,\n siteId: siteRow.id,\n reportType,\n period: options.period ?? periodEnd.toISOString().slice(0, 7),\n periodStart,\n periodEnd,\n completedOn,\n lighthouse: scores,\n lastTestedDate,\n ...(gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {}),\n ...(search ? { searchFoundPage1: search.foundOnPage1 } : {}),\n ...(search?.foundOnPage1 && search.position !== null\n ? { searchPosition: search.position }\n : {}),\n checklistTicks,\n autoEvidence,\n });\n\n await uploadDraftHtml(created.id, slug, periodEnd, html);\n const outcome = await queueDraft(base, {\n id: created.id,\n siteId: siteRow.id,\n reportType,\n });\n\n return {\n reportRow: created,\n htmlPath: null,\n html,\n softFailures,\n searchDefaultMissed: searchResult.defaultQueryMissed,\n searchPropertyMissing: searchResult.propertyMissing,\n queued: outcome.queued,\n supersededIds: outcome.supersededIds,\n };\n}\n\n/** Attach the rendered HTML to a Reports row. Queueing (Draft ready + the single-queue\n * reconciliation) is handled separately by queueDraft so both the create path and the\n * \"complete a half-made row\" path share the identical, re-runnable upload step. */\nasync function uploadDraftHtml(\n rowId: string,\n slug: string,\n periodEnd: Date,\n html: string,\n): Promise<void> {\n const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;\n await uploadAttachment(rowId, \"Rendered HTML\", html, htmlFilename, \"text/html\");\n}\n\n/** Result of an enrichment fetch: the value (null if unavailable) plus whether\n * it errored (`softFailed`) as opposed to being legitimately not-configured. */\ntype Enrichment<T> = { value: T | null; softFailed: boolean };\n/** A not-configured / skipped enrichment — null value, not a soft-failure. */\nconst NO_ENRICHMENT: Enrichment<never> = { value: null, softFailed: false };\n\n/**\n * Fetch GA \"Users\" for the period, soft-failing to null. Returns a null value (no enrichment)\n * when GA isn't configured (`GA_SUBJECT` unset) or the site has no GA4 property ID — those are\n * legitimate skips, `softFailed: false`. When the GA API errors it logs a one-line warning and\n * returns `softFailed: true`. Never throws, so a GA problem can never block a draft; the\n * operator can always enter the numbers by hand.\n */\nexport async function fetchGaUsers(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<Enrichment<{ current: number; previous: number }>> {\n const cfg = readGaConfig();\n if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;\n try {\n const value = await fetchPeriodUsers(\n { propertyId: siteRow.ga4PropertyId, subjects: cfg.subjects, keyPath: cfg.keyPath },\n periodStart,\n periodEnd,\n );\n return { value, softFailed: false };\n } catch (e) {\n console.warn(`⚠ GA skipped for ${siteRow.name}: ${(e as Error).message}`);\n return { value: null, softFailed: true };\n }\n}\n\n/** A search enrichment, plus the two distinct miss flags (see `fetchSearch` /\n * `DraftResult.searchDefaultMissed` / `DraftResult.searchPropertyMissing`) and the\n * environment-gap flag `notConfigured`. */\ntype SearchEnrichment = Enrichment<SearchPresence> & {\n defaultQueryMissed: boolean;\n propertyMissing: boolean;\n /** True when the site IS analytics-enrolled but this environment has no GA/SC\n * credentials (`GA_SUBJECT` unset) — so the check could not run at all. Distinct\n * from the un-enrolled skip (nothing to measure) and from a soft-fail (the API\n * errored). See {@link fetchSearch}. */\n notConfigured: boolean;\n};\n\n/**\n * Fetch the site's Google search presence for the period, soft-failing to null. Runs whenever\n * GA/SA is configured (`readGaConfig()` non-null — search shares the SA credentials) AND the\n * site is analytics-enrolled (has a `ga4PropertyId` OR an explicit `searchQuery`); otherwise a\n * legitimate skip (null value, `softFailed: false`). The brand query defaults to the site NAME\n * when no explicit `searchQuery` is set (whitespace-only counts as unset) — so brand presence is\n * tracked automatically, and the operator only hand-tunes the handful of sites the name misses.\n *\n * `propertyMissing` is true when NO Search Console property matched the site for any subject\n * (missing/unverified property or lost SA access) — flagged for BOTH explicit and name-default\n * queries, because no query can fix a missing property. Pre-split, this case was folded into\n * `defaultQueryMissed`, whose \"set an explicit Search query\" remedy would permanently SILENCE\n * it (an explicit query that finds nothing is by design never flagged).\n *\n * `defaultQueryMissed` is true ONLY when a property resolved, the site-name default was used,\n * AND Search Console returned no data for it (`position === null`) — the signal to set an\n * explicit `Search query`. It is false for an explicit query (even one that finds nothing —\n * that's a valid measurement), a default that does find a position, the not-enrolled skip,\n * and the errored soft-fail path.\n *\n * `notConfigured` splits the old single skip in two. An un-enrolled site is a true skip —\n * there is nothing to measure, and the checklist row stays manual. But an ENROLLED site in an\n * environment with no credentials is an environment gap: the check was supposed to run and\n * couldn't. Collapsing the two made a credential-less run (exactly what the daily-reports\n * workflow was) indistinguishable from \"this site opted out\", so the Google Indexed row\n * silently vanished from every drafted report instead of saying why.\n *\n * When the Search Console API errors it logs a one-line warning and returns `softFailed: true`.\n * Never throws, so a search problem can never block a draft.\n */\nexport async function fetchSearch(\n siteRow: WebsiteRow,\n periodStart: Date,\n periodEnd: Date,\n): Promise<SearchEnrichment> {\n const cfg = readGaConfig();\n const enrolled = Boolean(siteRow.ga4PropertyId || siteRow.searchQuery);\n if (!cfg || !enrolled) {\n const notConfigured = enrolled && !cfg;\n if (notConfigured) {\n console.warn(\n `⚑ Search: no GA/Search Console credentials in this environment (GA_SUBJECT unset) — the Google Indexed check could not run for ${siteRow.name}.`,\n );\n }\n return { ...NO_ENRICHMENT, defaultQueryMissed: false, propertyMissing: false, notConfigured };\n }\n const explicit = siteRow.searchQuery?.trim();\n const query = explicit || siteRow.name;\n const usedDefault = !explicit;\n try {\n const value = await fetchSearchPresence(\n {\n keyPath: cfg.keyPath,\n subjects: cfg.subjects,\n property: siteRow.searchConsoleProperty ?? undefined,\n host: siteRow.url,\n query,\n },\n periodStart,\n periodEnd,\n );\n const propertyMissing = !value.propertyFound;\n if (propertyMissing) {\n console.warn(\n `⚑ Search: no Search Console property matched ${siteRow.url} for ${siteRow.name} — verify the domain property exists and the service account has access. (A \"Search query\" change cannot fix this.)`,\n );\n }\n const defaultQueryMissed = value.propertyFound && usedDefault && value.position === null;\n if (defaultQueryMissed) {\n console.warn(\n `⚑ Search: site-name default \"${query}\" found no Search Console data for ${siteRow.name} — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n return { value, softFailed: false, defaultQueryMissed, propertyMissing, notConfigured: false };\n } catch (e) {\n console.warn(`⚠ Search presence skipped for ${siteRow.name}: ${(e as Error).message}`);\n return {\n value: null,\n softFailed: true,\n defaultQueryMissed: false,\n propertyMissing: false,\n notConfigured: false,\n };\n }\n}\n\nasync function derivePeriodStart(\n base: AirtableBase,\n siteRow: WebsiteRow,\n reportType: ReportType,\n today: Date,\n): Promise<Date> {\n const prior = await listReportsForSite(base, siteRow.id);\n const sameType = prior\n .filter((r) => r.reportType === reportType && r.periodEnd)\n .map((r) => r.periodEnd!)\n .sort();\n const latest = sameType[sameType.length - 1];\n if (!latest) return daysAgo(today, 30);\n // Half-open periods. The prior report's GA/Search windows are inclusive of its\n // periodEnd, so starting this report on the *same* day double-counts that\n // boundary day across two consecutive reports (and inflates the headline Users\n // count). Start the next day instead. UTC to stay TZ-consistent with daysAgo.\n const start = new Date(latest);\n start.setUTCDate(start.getUTCDate() + 1);\n return start;\n}\n","import type { ReportType } from \"./types.js\";\nimport type { WebsiteRow } from \"./airtable/websites.js\";\nimport type { SearchPresence } from \"./search/client.js\";\nimport { checklistFor, gatingFields } from \"./checklist.js\";\nimport { isNetlifyAppUrl } from \"../util/url.js\";\n\n/** A persisted audit signal is only trusted within this window — a stale \"pass\" degrades to\n * \"unknown\" (no tick). Matches the github-signals staleness convention (~3 days). */\nconst STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when `checkedAt` is a valid timestamp within STALE_DAYS of `now`. */\nfunction isFresh(checkedAt: string | null, now: Date): boolean {\n if (!checkedAt) return false;\n const t = new Date(checkedAt).getTime();\n if (Number.isNaN(t)) return false;\n return now.getTime() - t <= STALE_DAYS * MS_PER_DAY;\n}\n\n/** Cert-expiry buffer: a cert must have MORE than this many days left to pass (renew window).\n * Exactly 14 days does NOT pass — matches the spec's strict \">14 days\" and the domain audit's\n * own `> 14` pass status, so the two thresholds can't drift. */\nconst CERT_MIN_DAYS = 14;\n\n/** A single auto-check outcome. `pass` + fresh ⇒ the caller ticks the box. */\nexport type EvidenceResult = \"pass\" | \"fail\" | \"unknown\" | \"n/a\";\nexport type EvidenceRecord = { result: EvidenceResult; checkedAt: string | null; note: string };\n\n/**\n * Inline signals fetched during the draft. Phase 1 carries Search Console only; Phase 2/3 read\n * the remaining signals (security, domain, browser) off the Websites row passed as `site`.\n */\nexport type AutoTickSignals = {\n search: {\n value: SearchPresence | null;\n softFailed: boolean;\n /** The site is analytics-enrolled but this environment has no GA/Search Console\n * credentials, so the check could not run. Kept distinct from the un-enrolled skip\n * (`value: null`, this false) so the evidence can name the environment gap instead\n * of disappearing. */\n notConfigured: boolean;\n };\n};\n\n/**\n * Decide, per checklist item for this report type, an evidence record. PURE. The fail-safe\n * invariant lives here: a box is only tickable when `result === \"pass\"`. Google Indexed is an\n * inline signal (fetched at draft), so its evidence is inherently fresh (`checkedAt = now`).\n *\n * The semantic inversion: every GATING item for this report type gets a status even with no\n * signal — a never-measured gating item resolves to `unknown` (which blocks the send gate), not\n * an omission the caller could read as \"manual/OK\". Only non-gating (advisory) items keep the old\n * omit-when-absent behavior, so an unconfigured advisory check stays silently manual.\n */\nexport function autoTickChecklist(\n site: WebsiteRow,\n reportType: ReportType,\n now: Date,\n signals: AutoTickSignals,\n): Map<string, EvidenceRecord> {\n const out = new Map<string, EvidenceRecord>();\n const gating = new Set(gatingFields(reportType));\n\n for (const item of checklistFor(reportType)) {\n let ev: EvidenceRecord | null;\n switch (item.field) {\n case \"Maint: Google Indexed\":\n ev = googleEvidence(now, signals.search);\n break;\n case \"Maint: Deploy & Function Health\":\n ev = deployEvidence(site, now);\n break;\n case \"Maint: CMS Checked\":\n ev = cmsEvidence(site, now);\n break;\n case \"Maint: Domain, DNS & SSL\":\n ev = domainEvidence(site, now);\n break;\n case \"Maint: Security Updates\":\n ev = securityEvidence(site, now);\n break;\n case \"Maint: Uptime Checked\":\n ev = uptimeEvidence(site, now);\n break;\n case \"Test: Desktop Browsers\":\n ev = browserEvidence(\n site.crossbrowserOk,\n site,\n now,\n \"Desktop renders cleanly\",\n \"render errors\",\n );\n break;\n case \"Test: Mobile Browsers\":\n ev = browserEvidence(site.mobileOk, site, now, \"Mobile renders cleanly\", \"overflow/errors\");\n break;\n case \"Test: Page Titles & Meta\":\n ev = titlesEvidence(site, now);\n break;\n case \"Test: Links & Navigation\": {\n const broken = site.brokenLinks;\n const failNote = broken && broken > 0 ? `${broken} broken link(s)` : \"broken links / nav\";\n ev = browserEvidence(site.linksOk, site, now, \"All internal links resolve\", failNote);\n break;\n }\n case \"Test: Form Functionality\":\n ev = formsEvidence(site, now);\n break;\n case \"Test: Interactions & Animations\":\n ev = interactionsEvidence(site, now);\n break;\n case \"Test: Verified After Updates\":\n ev = updatesEvidence(site, now);\n break;\n default:\n ev = null;\n }\n // The semantic inversion: a GATING item with no fresh signal must still carry a status —\n // `unknown` (blocks) — so an unwired/absent signal can never leave the gate silently\n // passable. Advisory items (e.g. Google Indexed on a Maintenance report) keep the old\n // omit-when-absent behavior.\n if (ev === null && gating.has(item.field)) {\n ev = { result: \"unknown\", checkedAt: null, note: \"Not yet measured\" };\n }\n if (ev !== null) out.set(item.field, ev);\n }\n\n return out;\n}\n\n/** One browser-audit verdict (Desktop/Mobile/Links) → evidence, gated on `Browser checked at`\n * freshness. null verdict (never run) → omit (manual); stale → unknown; true → pass; false →\n * fail (with the failure note). The verdict itself was computed by the audit (summarizeBrowser). */\nfunction browserEvidence(\n ok: boolean | null,\n site: WebsiteRow,\n now: Date,\n passNote: string,\n failNote: string,\n): EvidenceRecord | null {\n if (ok === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Browser check is stale (>3d)\" };\n }\n return ok\n ? { result: \"pass\", checkedAt: at, note: passNote }\n : { result: \"fail\", checkedAt: at, note: failNote };\n}\n\n/**\n * Security Updates evidence from the persisted `security` audit. Omits (→ manual) when the audit\n * never ran (counts null / no timestamp); stale → unknown; 0 critical AND 0 high → pass; any\n * critical/high → fail (with the count). Honest scope: \"no known critical/high advisories in the\n * declared dependencies as of the last audit\" — moderate/low are advisory, not gating, and this\n * does not prove the fix is deployed.\n */\nfunction securityEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n const crit = site.securityVulnsCritical;\n const high = site.securityVulnsHigh;\n if (crit === null || high === null || !site.lastSecurityAuditAt) return null;\n const at = site.lastSecurityAuditAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Security audit is stale (>3d)\" };\n }\n if (crit === 0 && high === 0) {\n return { result: \"pass\", checkedAt: at, note: \"No known critical/high vulnerabilities\" };\n }\n return { result: \"fail\", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };\n}\n\n/**\n * Search Console → Google Indexed evidence. A soft-fail (the API errored) and an unwired\n * environment (`notConfigured`) are both `unknown`, but they carry DIFFERENT notes — an\n * operator staring at an amber pill has to be able to tell \"Google was unreachable this run\"\n * from \"this deployment has no Search Console credentials\", because only the second one is\n * fixed by wiring a secret. An un-enrolled site (`value: null`, not configured, not failed)\n * emits nothing so the box stays manual.\n *\n * Page-1 is `pass` (with the position in the note); off page 1 is `fail`.\n */\nfunction googleEvidence(now: Date, search: AutoTickSignals[\"search\"]): EvidenceRecord | null {\n const at = now.toISOString();\n if (search.softFailed) {\n return { result: \"unknown\", checkedAt: at, note: \"Search Console unavailable this run\" };\n }\n if (search.notConfigured) {\n return {\n result: \"unknown\",\n checkedAt: at,\n note: \"Search Console not configured in the environment that drafted this report\",\n };\n }\n if (search.value === null) return null;\n if (search.value.foundOnPage1) {\n const pos = search.value.position;\n return {\n result: \"pass\",\n checkedAt: at,\n note: `Page 1 on Google${pos !== null ? ` (#${pos})` : \"\"}`,\n };\n }\n const pos = search.value.position;\n return {\n result: \"fail\",\n checkedAt: at,\n note: `Not on page 1${pos !== null ? ` (avg #${pos})` : \"\"}`,\n };\n}\n\n/**\n * Domain/DNS/SSL evidence from the persisted `domain` audit. Omits (→ manual) when there's no\n * custom domain to verify (`*.netlify.app`) or the audit never ran. A `pass` requires a fresh\n * check that resolved with a cert comfortably before expiry; stale → unknown; resolved-but-no-cert\n * or near/past expiry → fail. The honest scope is resolve + valid cert — NOT registrar expiry,\n * www↔apex redirect, or MX.\n */\nfunction domainEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n // No custom domain to verify — the no-domain watch signal covers `*.netlify.app` elsewhere.\n if (!site.url || isNetlifyAppUrl(site.url)) return null;\n if (!site.domainCheckedAt) return null; // never probed → leave manual\n const at = site.domainCheckedAt;\n if (!isFresh(site.domainCheckedAt, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Domain check is stale (>3d)\" };\n }\n const days = site.certDaysRemaining;\n if (days === null) {\n return { result: \"fail\", checkedAt: at, note: \"Did not resolve, or no valid TLS cert\" };\n }\n if (days <= CERT_MIN_DAYS) {\n return { result: \"fail\", checkedAt: at, note: `TLS cert expires in ${days}d` };\n }\n return { result: \"pass\", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };\n}\n\n/**\n * Deploy & Function Health: the Netlify build is `ready` AND the deployed function responds\n * healthy. Two freshness stamps must both be fresh (deploy check + function-health check). Never\n * measured → null (omit → the gating dispatch coerces to unknown); either stale → unknown; both\n * fresh + ready + fn pass → pass; otherwise fail.\n */\nfunction deployEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.deployCheckedAt && !site.functionHealthCheckedAt) return null;\n if (!isFresh(site.deployCheckedAt, now) || !isFresh(site.functionHealthCheckedAt, now)) {\n return {\n result: \"unknown\",\n checkedAt: site.functionHealthCheckedAt ?? site.deployCheckedAt,\n note: \"Deploy/function-health check is stale (>3d)\",\n };\n }\n const ready = site.deployStatus === \"ready\";\n const fnOk = site.functionHealth === \"pass\";\n if (ready && fnOk) {\n return {\n result: \"pass\",\n checkedAt: site.functionHealthCheckedAt,\n note: \"Netlify build ready + functions respond\",\n };\n }\n const why =\n !ready && !fnOk\n ? \"build not ready + functions unhealthy\"\n : !ready\n ? \"build not ready\"\n : \"functions unhealthy\";\n return {\n result: \"fail\",\n checkedAt: site.functionHealthCheckedAt,\n note: `Deploy/function-health failing — ${why}`,\n };\n}\n\n/**\n * CMS Checked: the server-side `/health` Prismic probe reported reachable. Freshness rides the\n * function-health check stamp (one `/health` fetch feeds both Deploy and CMS). Never measured →\n * null; stale → unknown; pass/fail mirror the stored verdict; a fresh stamp with no verdict →\n * unknown.\n */\nfunction cmsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.functionHealthCheckedAt) return null;\n const at = site.functionHealthCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"CMS check is stale (>3d)\" };\n }\n if (site.cmsReachable === \"pass\") {\n return { result: \"pass\", checkedAt: at, note: \"Prismic reachable (server-side)\" };\n }\n if (site.cmsReachable === \"fail\") {\n return { result: \"fail\", checkedAt: at, note: \"Prismic unreachable (server-side)\" };\n }\n return { result: \"unknown\", checkedAt: at, note: \"CMS reachability not reported\" };\n}\n\n/**\n * Uptime Checked: every sampled route returned 2xx/3xx on the browser audit (point-in-time).\n * Freshness rides the shared `browserCheckedAt`. Never ran → null; stale → unknown.\n */\nfunction uptimeEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.reachableOk === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Uptime check is stale (>3d)\" };\n }\n return site.reachableOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"All sampled routes reachable (point-in-time)\" }\n : { result: \"fail\", checkedAt: at, note: \"One or more sampled routes did not respond 2xx/3xx\" };\n}\n\n/**\n * Page Titles & Meta: every sampled route had a non-empty title + meta description with no\n * duplicate titles (browser audit, chromium). Freshness rides `browserCheckedAt`.\n */\nfunction titlesEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.titleMetaOk === null || !site.browserCheckedAt) return null;\n const at = site.browserCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Titles/meta check is stale (>3d)\" };\n }\n return site.titleMetaOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"Titles + meta present\" }\n : {\n result: \"fail\",\n checkedAt: at,\n note: \"Missing/duplicate title or missing meta description\",\n };\n}\n\n/**\n * Form Functionality: a synthetic prod submission succeeded (form-e2e audit). `n/a` when the audit\n * ran (checked-at stamp set) but the site has no contact form (verdict cleared to null). Never ran\n * (no stamp) → null; stale → unknown.\n */\nfunction formsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (!site.formE2eCheckedAt) return null;\n const at = site.formE2eCheckedAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Form E2E check is stale (>3d)\" };\n }\n if (site.formE2eOk === \"pass\") {\n return { result: \"pass\", checkedAt: at, note: \"Synthetic submission succeeded\" };\n }\n if (site.formE2eOk === \"fail\") {\n return { result: \"fail\", checkedAt: at, note: \"Synthetic submission failed\" };\n }\n return { result: \"n/a\", checkedAt: at, note: \"No contact form on this site\" };\n}\n\n/**\n * Interactions & Animations: the per-site smoke suite is green. Freshness rides `lastSmokeAt`.\n * Never ran → null; stale → unknown.\n */\nfunction interactionsEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.smokeOk === null || !site.lastSmokeAt) return null;\n const at = site.lastSmokeAt;\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"Smoke suite is stale (>3d)\" };\n }\n return site.smokeOk === \"pass\"\n ? { result: \"pass\", checkedAt: at, note: \"Smoke suite green\" }\n : { result: \"fail\", checkedAt: at, note: \"Smoke suite red\" };\n}\n\n/**\n * Tested After Updates: default-branch CI is green on the latest commit (github-signals). A repo\n * with no CI (`defaultBranchCi === \"none\"`) is `n/a`. Never swept (null / no stamp) → null; stale\n * → unknown; passing → pass; failing → fail; pending → unknown.\n */\nfunction updatesEvidence(site: WebsiteRow, now: Date): EvidenceRecord | null {\n if (site.defaultBranchCi === null || !site.githubSignalsAt) return null;\n const at = site.githubSignalsAt;\n if (site.defaultBranchCi === \"none\") {\n return { result: \"n/a\", checkedAt: at, note: \"Repository has no CI\" };\n }\n if (!isFresh(at, now)) {\n return { result: \"unknown\", checkedAt: at, note: \"CI signal is stale (>3d)\" };\n }\n if (site.defaultBranchCi === \"passing\") {\n return { result: \"pass\", checkedAt: at, note: \"Default-branch CI green on latest commit\" };\n }\n if (site.defaultBranchCi === \"failing\") {\n return { result: \"fail\", checkedAt: at, note: \"Default-branch CI is failing\" };\n }\n return { result: \"unknown\", checkedAt: at, note: \"Default-branch CI is pending\" };\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { BetaAnalyticsDataClient } from \"@google-analytics/data\";\nimport { withSubjectFailover } from \"./failover.js\";\n\nconst ANALYTICS_READONLY = \"https://www.googleapis.com/auth/analytics.readonly\";\nconst MS_PER_DAY = 86_400_000;\n\nexport type GaQuery = {\n /** GA4 numeric property ID (e.g. \"471880366\"). */\n propertyId: string;\n /** Workspace users to impersonate via domain-wide delegation, tried in order\n * (auth-failure failover — see `withSubjectFailover`). */\n subjects: string[];\n /** Path to the service-account JSON key. */\n keyPath: string;\n};\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline's date handling. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * Fetch GA4 `activeUsers` (\"Users\") for a report period and the equal-length window\n * immediately before it, via a domain-wide-delegation service account impersonating each\n * of `subjects` in order until one authenticates. Throws on any non-auth API error, or\n * once every subject has failed auth — the caller (draftReportForSite) soft-fails.\n *\n * Previous window: same length as the current period, ending the day before `periodStart`.\n */\nexport async function fetchPeriodUsers(\n query: GaQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<{ current: number; previous: number }> {\n const key = JSON.parse(readFileSync(query.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n\n const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);\n const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);\n const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);\n const property = `properties/${query.propertyId}`;\n\n return withSubjectFailover(query.subjects, \"GA\", async (subject) => {\n const authClient = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [ANALYTICS_READONLY],\n subject,\n });\n const client = new BetaAnalyticsDataClient({ authClient });\n\n const run = async (start: Date, end: Date): Promise<number> => {\n const [resp] = await client.runReport({\n property,\n dateRanges: [{ startDate: ymd(start), endDate: ymd(end) }],\n metrics: [{ name: \"activeUsers\" }],\n });\n const raw = resp.rows?.[0]?.metricValues?.[0]?.value ?? \"0\";\n const n = Number.parseInt(raw, 10);\n return Number.isFinite(n) ? n : 0;\n };\n\n const current = await run(periodStart, periodEnd);\n const previous = await run(prevStart, prevEnd);\n return { current, previous };\n });\n}\n","/**\n * Impersonation-subject failover, shared by the GA Data client and the Search Console\n * client (both impersonate the same `GA_SUBJECT` list via domain-wide delegation).\n *\n * The point: one personal Workspace account backing every site's analytics is a fleet-wide\n * SPOF — if it's offboarded/suspended or loses property access, all analytics soft-fail to\n * blank at once. With `GA_SUBJECT` as an ordered list, an auth failure on one subject falls\n * through to the next, and a *partial* outage (primary dying, backup carrying) is surfaced\n * as a greppable `subject failover` warning before it becomes a total one.\n */\n\nconst AUTH_HTTP_STATUS = new Set([401, 403]);\n/** gRPC PERMISSION_DENIED (7) / UNAUTHENTICATED (16) — how the GA Data API surfaces auth. */\nconst AUTH_GRPC_CODES = new Set([7, 16]);\n/** Message shapes across the three auth surfaces: gRPC code names (GA Data), OAuth token\n * exchange (`invalid_grant` = suspended/deleted subject, `unauthorized_client` = DWD scope\n * missing), and plain HTTP 401/403 texts (Search Console via gaxios). */\nconst AUTH_MESSAGE =\n /permission[_ ]denied|unauthenticated|unauthorized|invalid_grant|access[_ ]denied|forbidden|insufficient permission|status code 40[13]/i;\n\ntype ErrorShape = {\n code?: number | string;\n status?: number | string;\n response?: { status?: number; data?: { error?: { errors?: Array<{ reason?: string }> } } };\n /** Explicit opt-in marker for errors that aren't auth-shaped per se but mean \"this\n * subject can't do the job, try the next one\" (see the search client's empty\n * sites.list case — that API hides inaccessible properties instead of 403ing). */\n failoverToNextSubject?: boolean;\n};\n\n/** Google surfaces per-user throughput caps as HTTP 403 with one of these `reason`s — a\n * transient rate-limit, NOT a lost grant. Distinguished so the failover warning doesn't\n * send operators to the offboarding runbook over a quota blip. */\nconst QUOTA_REASONS = new Set([\n \"userratelimitexceeded\",\n \"ratelimitexceeded\",\n \"quotaexceeded\",\n \"dailylimitexceeded\",\n]);\nconst QUOTA_MESSAGE = /quota exceeded|rate limit exceeded|user rate limit/i;\n\n/** True when an error looks like THIS subject's auth/authorization failed — the only\n * condition worth retrying under a different subject. Network/5xx/bad-input errors\n * would fail identically for every subject, so they propagate immediately. */\nexport function isAuthShapedError(e: unknown): boolean {\n const err = e as ErrorShape | null | undefined;\n if (err?.failoverToNextSubject === true) return true;\n if ([err?.status, err?.response?.status].some((s) => AUTH_HTTP_STATUS.has(Number(s))))\n return true;\n if (AUTH_HTTP_STATUS.has(Number(err?.code)) || AUTH_GRPC_CODES.has(Number(err?.code)))\n return true;\n const msg = e instanceof Error ? e.message : String(e);\n return AUTH_MESSAGE.test(msg);\n}\n\n/** A soft \"this subject can't see the resource, try the next one\" marker (the search\n * client's empty sites.list case) — NOT a genuine failure. It must never dominate a real\n * auth error when deciding what to throw after all subjects are exhausted. */\nfunction isFailoverSentinel(e: unknown): boolean {\n return (e as ErrorShape | null | undefined)?.failoverToNextSubject === true;\n}\n\n/** True when a 403 is a transient per-user quota/rate-limit, not an access loss. A\n * structured `reason` is authoritative (so a real `forbidden` 403 whose message happens to\n * mention a limit is NOT quota); otherwise fall back to the message. Still auth-shaped for\n * failover purposes — this only softens the warning wording, never the control flow. */\nexport function isQuotaShapedError(e: unknown): boolean {\n const err = e as ErrorShape | null | undefined;\n if (err?.failoverToNextSubject === true) return false;\n const reasons = err?.response?.data?.error?.errors;\n if (Array.isArray(reasons) && reasons.length > 0) {\n return reasons.some((r) => r?.reason != null && QUOTA_REASONS.has(r.reason.toLowerCase()));\n }\n const msg = e instanceof Error ? e.message : String(e);\n return QUOTA_MESSAGE.test(msg);\n}\n\n/**\n * Run `attempt` with each subject in order until one succeeds. Auth-shaped failures fall\n * through to the next subject; anything else throws immediately. When all subjects fail,\n * the LAST error is thrown — callers keep their existing catch-and-soft-fail behavior.\n * When an earlier subject failed but a later one succeeded, emit ONE console.warn so a\n * dying primary is visible in the cron log before it becomes a total outage.\n */\nexport async function withSubjectFailover<T>(\n subjects: readonly string[],\n label: string,\n attempt: (subject: string) => Promise<T>,\n): Promise<T> {\n if (subjects.length === 0) throw new Error(`${label}: no impersonation subjects configured`);\n const failed: string[] = [];\n // Track a genuine auth failure separately from a soft sentinel. If ANY subject failed for\n // real, that error must be what we throw — never a later subject's \"no resource here\"\n // sentinel. Otherwise [real-auth-failure, empty-result] would surface as a clean sentinel\n // and the caller would record it as verified data instead of soft-failing (the exact\n // masking the analytics-health alarm exists to catch).\n let lastRealError: unknown;\n let lastSentinel: unknown;\n // Stays true only while every failure so far is a transient quota/rate-limit — a lost\n // grant or a sentinel flips it, escalating the warning from \"transient\" to the runbook.\n let allQuota = true;\n for (const subject of subjects) {\n try {\n const result = await attempt(subject);\n if (failed.length > 0) {\n console.warn(\n allQuota\n ? `⚠ ${label} subject failover: ${failed.join(\"; \")} — succeeded via ${subject} ` +\n `(transient quota/rate-limit, not an access loss).`\n : `⚠ ${label} subject failover: ${failed.join(\"; \")} — succeeded via ${subject}. ` +\n `Restore the failing subject's access or drop it from GA_SUBJECT ` +\n `(docs/runbooks/ga-search-role-account-cutover.md).`,\n );\n }\n return result;\n } catch (e) {\n if (!isAuthShapedError(e)) throw e;\n failed.push(`${subject} (${e instanceof Error ? e.message : String(e)})`);\n if (isFailoverSentinel(e)) {\n lastSentinel = e;\n allQuota = false;\n } else {\n lastRealError = e;\n if (!isQuotaShapedError(e)) allQuota = false;\n }\n }\n }\n // A real auth failure dominates a sentinel regardless of subject order; only when EVERY\n // subject merely returned the soft sentinel do we throw it (the search client turns that\n // into a legitimate not-found).\n throw lastRealError ?? lastSentinel;\n}\n","import { readFileSync } from \"node:fs\";\nimport { JWT } from \"google-auth-library\";\nimport { withSubjectFailover } from \"../ga/failover.js\";\n\nconst WEBMASTERS_READONLY = \"https://www.googleapis.com/auth/webmasters.readonly\";\nconst SC_BASE = \"https://searchconsole.googleapis.com/webmasters/v3\";\n/** Average-position threshold for \"on page 1\" (10 organic results per page). */\nconst PAGE_1_MAX_POSITION = 10;\n/** Rows to pull for the brand `contains` match. searchAnalytics has no orderBy and returns\n * rows clicks-desc, so the exact-query row survives into the response only if it isn't paged\n * out beyond this cap. Sized with wide headroom — a brand has a handful of phrasing variants,\n * far below 100 — so the exact query (and every variant) is in practice always present. */\nexport const BRAND_QUERY_ROW_LIMIT = 100;\n\nexport type SearchPresenceQuery = {\n /** Path to the service-account JSON key (same one GA uses). */\n keyPath: string;\n /** Workspace users to impersonate via domain-wide delegation, tried in order\n * (auth-failure failover — see `withSubjectFailover`). */\n subjects: string[];\n /** Explicit Search Console property (`sc-domain:...` or `https://.../`). Overrides auto-resolution. */\n property?: string | undefined;\n /** Site host, used to auto-resolve the property from `sites.list` when `property` is absent. */\n host: string;\n /** Operator-supplied brand hint (e.g. the business name). Matched as a case-insensitive\n * SUBSTRING (`contains`) of the actual user query — NOT a hard exact string — so it tolerates\n * phrasing (\"red door creative\" vs \"…creative la\"). An exact-query match is preferred when\n * present; otherwise the most-searched matching query wins. */\n query: string;\n};\n\nexport type SearchPresence = {\n /** True when the average position for the query is on page 1 (<= 10). */\n foundOnPage1: boolean;\n /** Rounded average position, or null when not found / no data. */\n position: number | null;\n /** True when a Search Console property was actually resolved for the site (even if the\n * query then found no data). False ONLY when every subject resolved ZERO matching\n * properties — a missing/unverified property or lost SA access. Callers must\n * distinguish these: \"property exists, query missed\" is fixable with a better query;\n * \"no property at all\" is not, and prescribing a query change would silence the real\n * problem forever (an explicit query that returns nothing is by design never flagged). */\n propertyFound: boolean;\n};\n\ntype SiteEntry = { siteUrl: string };\n\n/** Reduce any property string or URL to a bare host: no `sc-domain:`, scheme, `www.`, path, lowercased. */\nexport function bareHost(s: string): string {\n return s\n .trim()\n .replace(/^sc-domain:/i, \"\")\n .replace(/^https?:\\/\\//i, \"\")\n .split(\"/\")[0]!\n .replace(/^www\\./i, \"\")\n .toLowerCase();\n}\n\n/**\n * All Search Console properties matching `host`, ordered for query fallback: Domain\n * (`sc-domain:`) forms first (broadest coverage), then URL-prefix forms. A site can be verified\n * as both; a freshly-created Domain property has no backfilled history, so its data can be empty\n * even while a long-lived URL-prefix property has data — hence we return every match and let the\n * caller try them in order until one returns data. Empty list = nothing matches.\n */\nexport function resolvePropertyCandidates(entries: SiteEntry[], host: string): string[] {\n const target = bareHost(host);\n const matches = entries.filter((e) => bareHost(e.siteUrl) === target).map((e) => e.siteUrl);\n const domains = matches.filter((s) => s.toLowerCase().startsWith(\"sc-domain:\"));\n const prefixes = matches.filter((s) => !s.toLowerCase().startsWith(\"sc-domain:\"));\n return [...domains, ...prefixes];\n}\n\n/** UTC YYYY-MM-DD — matches the rest of the reports pipeline. */\nfunction ymd(d: Date): string {\n return d.toISOString().slice(0, 10);\n}\n\n/**\n * From the queries matching the brand substring, pick the one the brand is actually FOUND\n * by most — the highest-impression row — and return its average position; tie-break on the\n * better (lower) position. Returns undefined when no row carries a numeric position. This is\n * what makes the reported \"brand search\" position robust to phrasing: with a `contains`\n * filter several variants can match, and we report the position of the variant real users\n * search most, not whichever the operator happened to type. Rows missing `impressions` count\n * as 0 (keeps a single-row, impression-less response — e.g. a test fixture — working). PURE.\n */\nexport function pickBrandQuery(\n rows: Array<{ position?: number; impressions?: number }>,\n): number | undefined {\n let best: { position: number; impressions: number } | undefined;\n for (const r of rows) {\n if (typeof r.position !== \"number\") continue;\n const impressions = r.impressions ?? 0;\n if (\n best === undefined ||\n impressions > best.impressions ||\n (impressions === best.impressions && r.position < best.position)\n ) {\n best = { position: r.position, impressions };\n }\n }\n return best?.position;\n}\n\n/**\n * The brand's reported position from the `contains`-matched rows, preferring an EXACT match.\n * When a returned row's query equals the operator's configured string (case-insensitive), its\n * position is used verbatim — a precisely-configured brand query is honored, with no chance a\n * longer higher-impression variant (\"…reviews\", \"…near me\") hijacks the number. Otherwise we\n * fall back to the most-searched matching query (`pickBrandQuery`). Operates only on the rows\n * actually returned: a string `contains` itself, so the exact query is among the `contains`\n * results whenever it has data AND isn't paged out beyond `BRAND_QUERY_ROW_LIMIT` (sized so a\n * brand's handful of phrasing variants always fit; if it ever were paged out, the most-searched\n * fallback still returns a valid brand position). Returns undefined when no row carries a\n * numeric position. PURE.\n */\nexport function selectBrandPosition(\n rows: Array<{ keys?: string[]; position?: number; impressions?: number }>,\n exactQuery: string,\n): number | undefined {\n const want = exactQuery.toLowerCase();\n const exact = rows.find(\n (r) => typeof r.position === \"number\" && (r.keys?.[0] ?? \"\").toLowerCase() === want,\n );\n if (exact) return exact.position;\n return pickBrandQuery(rows);\n}\n\n/** Per-subject \"this subject resolved NO matching Search Console property\". sites.list only\n * returns properties the impersonated user can access — a subject that lost access sees an\n * empty list with no auth error at all — so this opts into subject failover explicitly.\n * When EVERY subject resolves empty, `fetchSearchPresence` restores the pre-failover\n * behavior: a plain not-found result, not a throw. */\nclass NoPropertyForSubjectError extends Error {\n readonly failoverToNextSubject = true;\n constructor(subject: string, host: string) {\n super(`no Search Console property matching ${host} is visible to ${subject}`);\n }\n}\n\n/**\n * Query Google Search Console for the brand's average position on the site over the report\n * period, via a domain-wide-delegation service account impersonating each of `subjects` in\n * order until one authenticates. `query` is a case-insensitive SUBSTRING (`contains`) hint;\n * among the matching user queries we report the position of the exact-match query when\n * present, else the most-searched one (see `selectBrandPosition`) — so it doesn't depend on\n * the operator typing the exact search string, yet honors one verbatim when they do. Uses\n * `property` verbatim when given (operator's choice is final — no fallback); otherwise\n * auto-discovers all matching properties via `sites.list` and tries them in order (Domain\n * first) until one returns data. Throws on any non-auth API error, or once every subject\n * has failed auth — the caller (draftReportForSite) soft-fails.\n */\nexport async function fetchSearchPresence(\n q: SearchPresenceQuery,\n periodStart: Date,\n periodEnd: Date,\n): Promise<SearchPresence> {\n const key = JSON.parse(readFileSync(q.keyPath, \"utf8\")) as {\n client_email: string;\n private_key: string;\n };\n\n try {\n return await withSubjectFailover(q.subjects, \"Search Console\", async (subject) => {\n const jwt = new JWT({\n email: key.client_email,\n key: key.private_key,\n scopes: [WEBMASTERS_READONLY],\n subject,\n });\n\n const explicit = q.property?.trim();\n let candidates: string[];\n if (explicit) {\n candidates = [explicit];\n } else {\n const list = await jwt.request<{ siteEntry?: SiteEntry[] }>({\n url: `${SC_BASE}/sites`,\n method: \"GET\",\n });\n candidates = resolvePropertyCandidates(list.data.siteEntry ?? [], q.host);\n if (candidates.length === 0) throw new NoPropertyForSubjectError(subject, q.host);\n }\n\n for (const property of candidates) {\n const res = await jwt.request<{\n rows?: Array<{ keys?: string[]; position?: number; impressions?: number }>;\n }>({\n url: `${SC_BASE}/sites/${encodeURIComponent(property)}/searchAnalytics/query`,\n method: \"POST\",\n data: {\n startDate: ymd(periodStart),\n endDate: ymd(periodEnd),\n dimensions: [\"query\"],\n dimensionFilterGroups: [\n {\n filters: [\n { dimension: \"query\", operator: \"contains\", expression: q.query.toLowerCase() },\n ],\n },\n ],\n rowLimit: BRAND_QUERY_ROW_LIMIT,\n },\n });\n const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());\n if (typeof pos === \"number\") {\n // Search Console can average below 1; floor to 1 so the template never\n // renders a nonsensical \"#0\" (positions are 1-indexed).\n return {\n foundOnPage1: pos <= PAGE_1_MAX_POSITION,\n position: Math.max(1, Math.round(pos)),\n propertyFound: true,\n };\n }\n }\n // A property WAS resolved (or operator-pinned); the query just found no rows.\n return { foundOnPage1: false, position: null, propertyFound: true };\n });\n } catch (e) {\n // Every subject resolved zero matching properties — same legitimate \"no property\"\n // outcome as before failover existed, NOT an analytics soft-failure. Marked\n // propertyFound:false so the draft layer can flag the ACTUAL problem (missing\n // property / lost SA access) instead of blaming the query.\n if (e instanceof NoPropertyForSubjectError)\n return { foundOnPage1: false, position: null, propertyFound: false };\n throw e;\n }\n}\n"],"mappings":"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","names":["dirname","pos","MS_PER_DAY","readFileSync","JWT","ymd","readFileSync","JWT","dirname","outcome"]}
|