@reddoorla/maintenance 0.95.1 → 0.97.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 +41 -7
- package/dist/{announce-CLQYX5I4.js → announce-5ZD2SBGD.js} +39 -31
- package/dist/announce-5ZD2SBGD.js.map +1 -0
- package/dist/attachments-JFGFQRXQ.js +10 -0
- package/dist/{blux-4KNXUP2K.js → blux-NXQLMS4W.js} +1 -1
- package/dist/{bump-deps-C5G2EFBB.js → bump-deps-775VLDYB.js} +6 -6
- package/dist/{chunk-FUWFLAWQ.js → chunk-3RLDX3Q2.js} +5 -5
- package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
- package/dist/chunk-43SW3FGW.js.map +1 -0
- package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
- package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
- package/dist/{chunk-S7RYFUIK.js → chunk-6PGXKN6F.js} +2 -2
- package/dist/{chunk-HTABUCF4.js → chunk-6THEZQLA.js} +3 -3
- package/dist/chunk-A5QKNOXW.js +72 -0
- package/dist/chunk-A5QKNOXW.js.map +1 -0
- package/dist/chunk-ASLDCWBF.js +41 -0
- package/dist/chunk-ASLDCWBF.js.map +1 -0
- package/dist/{chunk-CWXTNPNZ.js → chunk-AYDQQBNG.js} +3 -3
- package/dist/chunk-AYDQQBNG.js.map +1 -0
- package/dist/{chunk-WVNDEYXL.js → chunk-BINFZQ2T.js} +57 -6
- package/dist/chunk-BINFZQ2T.js.map +1 -0
- package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
- package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
- package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
- package/dist/chunk-CXICCLNM.js.map +1 -0
- package/dist/{chunk-BYGPUUCT.js → chunk-DRNZVORX.js} +66 -30
- package/dist/chunk-DRNZVORX.js.map +1 -0
- package/dist/{chunk-LM2NHRCJ.js → chunk-DYTYPKFN.js} +4 -4
- package/dist/chunk-DYTYPKFN.js.map +1 -0
- package/dist/{chunk-DFKBTBO3.js → chunk-EN6CTBUN.js} +35 -6
- package/dist/chunk-EN6CTBUN.js.map +1 -0
- package/dist/{chunk-5EEMTJMT.js → chunk-FHFXJ3EM.js} +8 -6
- package/dist/chunk-FHFXJ3EM.js.map +1 -0
- package/dist/{chunk-Z2K4JPQ3.js → chunk-FP6HOW6J.js} +10 -7
- package/dist/chunk-FP6HOW6J.js.map +1 -0
- package/dist/{chunk-2LYOAB2E.js → chunk-HSMN35LU.js} +2 -2
- package/dist/chunk-ICSRWW5Q.js +148 -0
- package/dist/chunk-ICSRWW5Q.js.map +1 -0
- package/dist/{chunk-FSGICFXB.js → chunk-IDBPII6G.js} +17 -5
- package/dist/chunk-IDBPII6G.js.map +1 -0
- package/dist/{chunk-C3IQQKXL.js → chunk-IOTOOTII.js} +121 -1
- package/dist/{chunk-C3IQQKXL.js.map → chunk-IOTOOTII.js.map} +1 -1
- package/dist/{chunk-BCKEP5S6.js → chunk-JHJNNJLG.js} +2 -2
- package/dist/{chunk-TO7DJYLZ.js → chunk-L3S3QZXL.js} +178 -39
- package/dist/chunk-L3S3QZXL.js.map +1 -0
- package/dist/{chunk-WSJ7RO54.js → chunk-LAG6IP7Y.js} +23 -5
- package/dist/chunk-LAG6IP7Y.js.map +1 -0
- package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
- package/dist/chunk-LAJ3FF54.js.map +1 -0
- package/dist/{chunk-2ZAZ7ETT.js → chunk-LDHRIQUW.js} +32 -18
- package/dist/chunk-LDHRIQUW.js.map +1 -0
- package/dist/{chunk-6R56OKJZ.js → chunk-LF2TJN4X.js} +2 -2
- package/dist/chunk-LF2TJN4X.js.map +1 -0
- package/dist/{chunk-ZQV7UQNE.js → chunk-LUF52EIY.js} +2 -2
- package/dist/{chunk-5UINJNAJ.js → chunk-LZMAG73U.js} +34 -21
- package/dist/chunk-LZMAG73U.js.map +1 -0
- package/dist/{chunk-VSGMCCGQ.js → chunk-MMSMZC7S.js} +2 -2
- package/dist/chunk-MVKNU4QD.js +37 -0
- package/dist/chunk-MVKNU4QD.js.map +1 -0
- package/dist/{chunk-XPJQXSC4.js → chunk-P7323YVW.js} +27 -5
- package/dist/chunk-P7323YVW.js.map +1 -0
- package/dist/{chunk-KJRVMMM3.js → chunk-PARKXDXI.js} +36 -5
- package/dist/chunk-PARKXDXI.js.map +1 -0
- package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
- package/dist/{chunk-WMBLR5VA.js → chunk-PZZ6HM5T.js} +3 -3
- package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
- package/dist/chunk-QFSL2L2B.js.map +1 -0
- package/dist/chunk-RQJFGK3A.js +51 -0
- package/dist/chunk-RQJFGK3A.js.map +1 -0
- package/dist/{chunk-3Z6X3BHU.js → chunk-TC4GANMX.js} +53 -150
- package/dist/chunk-TC4GANMX.js.map +1 -0
- package/dist/{chunk-F3JWOPTG.js → chunk-TEMQPSCM.js} +45 -105
- package/dist/chunk-TEMQPSCM.js.map +1 -0
- package/dist/chunk-TJLKWQOX.js +52 -0
- package/dist/chunk-TJLKWQOX.js.map +1 -0
- package/dist/{chunk-CJ2RMTT7.js → chunk-TV76A6GN.js} +2 -2
- package/dist/{chunk-KZRR4NFG.js → chunk-TVZ6QCOE.js} +3 -3
- package/dist/{chunk-BVEDKMNG.js → chunk-U2ZI6G7H.js} +29 -11
- package/dist/chunk-U2ZI6G7H.js.map +1 -0
- package/dist/chunk-UPE6D4AT.js +69 -0
- package/dist/chunk-UPE6D4AT.js.map +1 -0
- package/dist/{chunk-R5IMFAFR.js → chunk-VMGHQUTV.js} +7 -16
- package/dist/chunk-VMGHQUTV.js.map +1 -0
- package/dist/chunk-VQMAAQ5M.js +53 -0
- package/dist/chunk-VQMAAQ5M.js.map +1 -0
- package/dist/{chunk-AVEQUR44.js → chunk-WMF5S2MB.js} +60 -17
- package/dist/chunk-WMF5S2MB.js.map +1 -0
- package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
- package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
- package/dist/{chunk-XN37VTDB.js → chunk-X56LAKPR.js} +3 -3
- package/dist/chunk-X56LAKPR.js.map +1 -0
- package/dist/{chunk-ZCL7C5LH.js → chunk-Y6FCXRIC.js} +1661 -387
- package/dist/chunk-Y6FCXRIC.js.map +1 -0
- package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
- package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
- package/dist/{chunk-IY6256GK.js → chunk-YJBSB4LP.js} +6 -1
- package/dist/chunk-YJBSB4LP.js.map +1 -0
- package/dist/{chunk-BI2VI6EM.js → chunk-YXWBM26P.js} +2 -2
- package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
- package/dist/chunk-Z7FDJ35L.js.map +1 -0
- package/dist/cli/bin.js +111 -51
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +33 -3
- package/dist/cli/commands/audit.js +28 -25
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-DGTO65WB.js +11 -0
- package/dist/configs/playwright-a11y.d.ts +11 -1
- package/dist/configs/playwright-a11y.js +3 -1
- package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-KLWA53N5.js} +6 -6
- package/dist/{db-XKZYDCKM.js → db-II5WAK4D.js} +45 -20
- package/dist/db-II5WAK4D.js.map +1 -0
- package/dist/deadletter-G4M3HSVM.js +17 -0
- package/dist/{digest-7UBGUBRK.js → digest-CWKK2DMR.js} +56 -32
- package/dist/digest-CWKK2DMR.js.map +1 -0
- package/dist/{digest-collectors-DJFN5ZDK.js → digest-collectors-CYTN2EUX.js} +17 -12
- package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
- package/dist/digest-state-ZDPKR7MW.js.map +1 -0
- package/dist/ensure-site-2QZHFVQ3.js +70 -0
- package/dist/ensure-site-2QZHFVQ3.js.map +1 -0
- package/dist/ensure-site-JEKV2ODE.js +105 -0
- package/dist/ensure-site-JEKV2ODE.js.map +1 -0
- package/dist/{ensure-site-OAAE5YE2.js → ensure-site-OO4UPWNQ.js} +49 -44
- package/dist/ensure-site-OO4UPWNQ.js.map +1 -0
- package/dist/fleet-events-CkOZpMVC.d.ts +435 -0
- package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-RJROYXKO.js} +19 -7
- package/dist/{forms-notify-target-UC6PF3AE.js → forms-notify-target-X6UEOI2M.js} +19 -15
- package/dist/forms-notify-target-X6UEOI2M.js.map +1 -0
- package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
- package/dist/{github-signals-TX5FPYP6.js → github-signals-QIRTHHET.js} +22 -17
- package/dist/github-signals-QIRTHHET.js.map +1 -0
- package/dist/{header-image-PSPT7CUM.js → header-image-H7ZJAV26.js} +15 -18
- package/dist/header-image-H7ZJAV26.js.map +1 -0
- package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-BJ6BKRHF.js} +8 -7
- package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-BJ6BKRHF.js.map} +1 -1
- package/dist/{health-mirror-XJAKA3EY.js → health-mirror-KU3G7V2Y.js} +10 -10
- package/dist/{import-airtable-TAKSN4NV.js → import-airtable-UBF5GYAL.js} +3 -3
- package/dist/index.d.ts +158 -419
- package/dist/index.js +91 -46
- package/dist/index.js.map +1 -1
- package/dist/{init-FFE4FCO2.js → init-GOWDA5QS.js} +19 -18
- package/dist/{init-FFE4FCO2.js.map → init-GOWDA5QS.js.map} +1 -1
- package/dist/{launch-UD36AHUN.js → launch-7N3X2MXB.js} +229 -79
- package/dist/launch-7N3X2MXB.js.map +1 -0
- package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
- package/dist/lighthouse-airtable-M45OPVRV.js +14 -0
- package/dist/match-harness-GBCDSVPS.js +417 -0
- package/dist/match-harness-GBCDSVPS.js.map +1 -0
- package/dist/migrate-XZ7U5DFC.js +7 -0
- package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +4 -4
- package/dist/{onboard-TM2KESGF.js → onboard-JQQIMQW7.js} +6 -6
- package/dist/{orchestrate-HYVQPRCE.js → orchestrate-L2A5BZFY.js} +13 -10
- package/dist/parity-2JGVUCZD.js +14 -0
- package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
- package/dist/{preflight-RYIP54NY.js → preflight-O7Q6FWHU.js} +35 -11
- package/dist/preflight-O7Q6FWHU.js.map +1 -0
- package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-NAPWOXXD.js} +39 -9
- package/dist/prismic-ci-NAPWOXXD.js.map +1 -0
- package/dist/{prismic-models-P6FWWVRS.js → prismic-models-LL6ORTQ2.js} +20 -17
- package/dist/prismic-models-LL6ORTQ2.js.map +1 -0
- package/dist/prospect/types.d.ts +72 -0
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-PRHU53HS.js → prospect-audit-FMPGQIRQ.js} +16 -7
- package/dist/prospect-audit-FMPGQIRQ.js.map +1 -0
- package/dist/{prospect-audits-R3LWF6U7.js → prospect-audits-IZVQY3OQ.js} +4 -2
- package/dist/protection-audit-YXJKFU6O.js +413 -0
- package/dist/protection-audit-YXJKFU6O.js.map +1 -0
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
- package/dist/render-MHUUHPLN.js.map +1 -0
- package/dist/{render-from-row-IFSBHWDH.js → render-from-row-75JTED2Z.js} +6 -4
- package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-2IHAZDVA.js} +14 -9
- package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-2IHAZDVA.js.map} +1 -1
- package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
- package/dist/replay-4FVFOMHH.js.map +1 -0
- package/dist/{report-LQS4ECTM.js → report-IAHXN5CW.js} +103 -57
- package/dist/report-IAHXN5CW.js.map +1 -0
- package/dist/{report-mirror-HDTOJE64.js → report-mirror-4QM57LMS.js} +34 -12
- package/dist/report-mirror-4QM57LMS.js.map +1 -0
- package/dist/roster-GDNSIRAK.js +19 -0
- package/dist/roster-GDNSIRAK.js.map +1 -0
- package/dist/{schema-A4WCPIF4.js → schema-KWUQEZ5O.js} +1 -1
- package/dist/schema-KWUQEZ5O.js.map +1 -0
- package/dist/{self-updating-MWXIZLL7.js → self-updating-ZUWPGYUP.js} +8 -7
- package/dist/{self-updating-MWXIZLL7.js.map → self-updating-ZUWPGYUP.js.map} +1 -1
- package/dist/{selftest-VXTHRPRN.js → selftest-G4W3LIBQ.js} +21 -17
- package/dist/{selftest-VXTHRPRN.js.map → selftest-G4W3LIBQ.js.map} +1 -1
- package/dist/site-create-ZU3ACBTO.js +45 -0
- package/dist/site-create-ZU3ACBTO.js.map +1 -0
- package/dist/site-lookup-DTOLJ4EA.js +27 -0
- package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
- package/dist/{site-mirror-WON4FTJ2.js → site-mirror-WR7ACXOV.js} +26 -12
- package/dist/site-mirror-WR7ACXOV.js.map +1 -0
- package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-WAUPI6EJ.js} +8 -7
- package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-WAUPI6EJ.js.map} +1 -1
- package/dist/{submissions-OVZPVBYG.js → submissions-2SHCFOKD.js} +12 -7
- package/dist/submissions-2SHCFOKD.js.map +1 -0
- package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
- package/dist/submissions-JDZP35JM.js.map +1 -0
- package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-VOLOKKFI.js} +6 -6
- package/dist/{sync-K3PKHVLH.js → sync-F4USL4K5.js} +6 -6
- package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-OXSWKVZ2.js} +6 -6
- package/dist/turso-KQKBBQCK.js +24 -0
- package/dist/turso-KQKBBQCK.js.map +1 -0
- package/dist/{upgrade-VTD6V4HS.js → upgrade-EVAL3JTO.js} +6 -6
- package/dist/util/git.d.ts +49 -10
- package/dist/util/git.js +1 -1
- package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
- package/dist/{websites-TB4TCAWM.js → websites-Y6FN2XFD.js} +27 -20
- package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-TI3JMWSY.js} +7 -5
- package/package.json +3 -2
- package/dist/airtable-JGCPS7JH.js +0 -9
- package/dist/announce-CLQYX5I4.js.map +0 -1
- package/dist/attachments-NLZIJ5NU.js +0 -9
- package/dist/chunk-2ZAZ7ETT.js.map +0 -1
- package/dist/chunk-3Z6X3BHU.js.map +0 -1
- package/dist/chunk-5EEMTJMT.js.map +0 -1
- package/dist/chunk-5GUJ36TL.js +0 -53
- package/dist/chunk-5GUJ36TL.js.map +0 -1
- package/dist/chunk-5UINJNAJ.js.map +0 -1
- package/dist/chunk-6R56OKJZ.js.map +0 -1
- package/dist/chunk-AVEQUR44.js.map +0 -1
- package/dist/chunk-BVEDKMNG.js.map +0 -1
- package/dist/chunk-BYGPUUCT.js.map +0 -1
- package/dist/chunk-CWXTNPNZ.js.map +0 -1
- package/dist/chunk-DFKBTBO3.js.map +0 -1
- package/dist/chunk-F3JWOPTG.js.map +0 -1
- package/dist/chunk-FSGICFXB.js.map +0 -1
- package/dist/chunk-GASBX52O.js +0 -12
- package/dist/chunk-GASBX52O.js.map +0 -1
- package/dist/chunk-IY6256GK.js.map +0 -1
- package/dist/chunk-KJRVMMM3.js.map +0 -1
- package/dist/chunk-LM2NHRCJ.js.map +0 -1
- package/dist/chunk-QF6IQQFP.js.map +0 -1
- package/dist/chunk-R5IMFAFR.js.map +0 -1
- package/dist/chunk-TO7DJYLZ.js.map +0 -1
- package/dist/chunk-TR3HFH6J.js.map +0 -1
- package/dist/chunk-WJMPCVOR.js.map +0 -1
- package/dist/chunk-WSJ7RO54.js.map +0 -1
- package/dist/chunk-WVNDEYXL.js.map +0 -1
- package/dist/chunk-XHHMM5HX.js.map +0 -1
- package/dist/chunk-XN37VTDB.js.map +0 -1
- package/dist/chunk-XPJQXSC4.js.map +0 -1
- package/dist/chunk-YFAXOD7Z.js +0 -42
- package/dist/chunk-YFAXOD7Z.js.map +0 -1
- package/dist/chunk-YLYXW5PH.js.map +0 -1
- package/dist/chunk-Z2K4JPQ3.js.map +0 -1
- package/dist/chunk-ZCL7C5LH.js.map +0 -1
- package/dist/client-G2WGWPCA.js +0 -11
- package/dist/db-XKZYDCKM.js.map +0 -1
- package/dist/digest-7UBGUBRK.js.map +0 -1
- package/dist/digest-state-3KHZF7GD.js.map +0 -1
- package/dist/ensure-site-OAAE5YE2.js.map +0 -1
- package/dist/fleet-events-cF2Om88w.d.ts +0 -24
- package/dist/forms-notify-target-UC6PF3AE.js.map +0 -1
- package/dist/github-signals-TX5FPYP6.js.map +0 -1
- package/dist/header-image-PSPT7CUM.js.map +0 -1
- package/dist/launch-UD36AHUN.js.map +0 -1
- package/dist/lighthouse-airtable-YMH4UA7R.js +0 -12
- package/dist/match-harness-QRG6XZAR.js +0 -70
- package/dist/match-harness-QRG6XZAR.js.map +0 -1
- package/dist/migrate-Y7R4CESL.js +0 -7
- package/dist/parity-CAJ477PS.js +0 -14
- package/dist/preflight-RYIP54NY.js.map +0 -1
- package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
- package/dist/prismic-models-P6FWWVRS.js.map +0 -1
- package/dist/prospect-audit-PRHU53HS.js.map +0 -1
- package/dist/protection-audit-SJO53MEQ.js +0 -168
- package/dist/protection-audit-SJO53MEQ.js.map +0 -1
- package/dist/render-YGNGTX3W.js.map +0 -1
- package/dist/replay-PCSQOGCZ.js.map +0 -1
- package/dist/report-LQS4ECTM.js.map +0 -1
- package/dist/report-mirror-HDTOJE64.js.map +0 -1
- package/dist/schema-A4WCPIF4.js.map +0 -1
- package/dist/site-mirror-WON4FTJ2.js.map +0 -1
- package/dist/submissions-3I3GAQAX.js.map +0 -1
- package/dist/submissions-OVZPVBYG.js.map +0 -1
- /package/dist/{airtable-JGCPS7JH.js.map → attachments-JFGFQRXQ.js.map} +0 -0
- /package/dist/{blux-4KNXUP2K.js.map → blux-NXQLMS4W.js.map} +0 -0
- /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-775VLDYB.js.map} +0 -0
- /package/dist/{chunk-FUWFLAWQ.js.map → chunk-3RLDX3Q2.js.map} +0 -0
- /package/dist/{chunk-S7RYFUIK.js.map → chunk-6PGXKN6F.js.map} +0 -0
- /package/dist/{chunk-HTABUCF4.js.map → chunk-6THEZQLA.js.map} +0 -0
- /package/dist/{chunk-2LYOAB2E.js.map → chunk-HSMN35LU.js.map} +0 -0
- /package/dist/{chunk-BCKEP5S6.js.map → chunk-JHJNNJLG.js.map} +0 -0
- /package/dist/{chunk-ZQV7UQNE.js.map → chunk-LUF52EIY.js.map} +0 -0
- /package/dist/{chunk-VSGMCCGQ.js.map → chunk-MMSMZC7S.js.map} +0 -0
- /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
- /package/dist/{chunk-WMBLR5VA.js.map → chunk-PZZ6HM5T.js.map} +0 -0
- /package/dist/{chunk-CJ2RMTT7.js.map → chunk-TV76A6GN.js.map} +0 -0
- /package/dist/{chunk-KZRR4NFG.js.map → chunk-TVZ6QCOE.js.map} +0 -0
- /package/dist/{chunk-BI2VI6EM.js.map → chunk-YXWBM26P.js.map} +0 -0
- /package/dist/{attachments-NLZIJ5NU.js.map → client-DGTO65WB.js.map} +0 -0
- /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-KLWA53N5.js.map} +0 -0
- /package/dist/{client-G2WGWPCA.js.map → deadletter-G4M3HSVM.js.map} +0 -0
- /package/dist/{digest-collectors-DJFN5ZDK.js.map → digest-collectors-CYTN2EUX.js.map} +0 -0
- /package/dist/{fleet-state-UEVZ3X3J.js.map → fleet-state-RJROYXKO.js.map} +0 -0
- /package/dist/{freeze-A6MUCZU4.js.map → freeze-GUQ43EAU.js.map} +0 -0
- /package/dist/{health-mirror-XJAKA3EY.js.map → health-mirror-KU3G7V2Y.js.map} +0 -0
- /package/dist/{import-airtable-TAKSN4NV.js.map → import-airtable-UBF5GYAL.js.map} +0 -0
- /package/dist/{lighthouse-VHI77PFB.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
- /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-airtable-M45OPVRV.js.map} +0 -0
- /package/dist/{migrate-Y7R4CESL.js.map → migrate-XZ7U5DFC.js.map} +0 -0
- /package/dist/{notify-IISTHNBR.js.map → notify-K3WVH3DM.js.map} +0 -0
- /package/dist/{onboard-TM2KESGF.js.map → onboard-JQQIMQW7.js.map} +0 -0
- /package/dist/{orchestrate-HYVQPRCE.js.map → orchestrate-L2A5BZFY.js.map} +0 -0
- /package/dist/{parity-CAJ477PS.js.map → parity-2JGVUCZD.js.map} +0 -0
- /package/dist/{pipeline-YROAY6DT.js.map → pipeline-UUPXOS2J.js.map} +0 -0
- /package/dist/{prospect-audits-R3LWF6U7.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
- /package/dist/{render-from-row-IFSBHWDH.js.map → render-from-row-75JTED2Z.js.map} +0 -0
- /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-VOLOKKFI.js.map} +0 -0
- /package/dist/{sync-K3PKHVLH.js.map → sync-F4USL4K5.js.map} +0 -0
- /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-OXSWKVZ2.js.map} +0 -0
- /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-EVAL3JTO.js.map} +0 -0
- /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
- /package/dist/{websites-TB4TCAWM.js.map → websites-Y6FN2XFD.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → write-audits-to-airtable-TI3JMWSY.js.map} +0 -0
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
rulesetGaps
|
|
3
|
-
} from "./chunk-MNDIED4M.js";
|
|
4
|
-
import {
|
|
5
|
-
makeGitHub
|
|
6
|
-
} from "./chunk-XHHMM5HX.js";
|
|
7
|
-
import "./chunk-LBYOLBW7.js";
|
|
8
|
-
|
|
9
|
-
// src/audits/protection-coverage.ts
|
|
10
|
-
var RENOVATE_WORKFLOW_FILE = "renovate.yml";
|
|
11
|
-
var RENOVATE_STALE_AFTER_DAYS = 3;
|
|
12
|
-
var ACCEPTED_GAPS = [];
|
|
13
|
-
function partitionAcceptedGaps(repo, gaps, now, accepted = ACCEPTED_GAPS) {
|
|
14
|
-
const live = [];
|
|
15
|
-
const acceptedOut = [];
|
|
16
|
-
for (const gap of gaps) {
|
|
17
|
-
const match = accepted.find(
|
|
18
|
-
(a) => a.repo === repo && gap.startsWith(a.detailPrefix) && now < new Date(a.until)
|
|
19
|
-
);
|
|
20
|
-
if (match) acceptedOut.push(`${gap} [accepted until ${match.until}: ${match.reason}]`);
|
|
21
|
-
else live.push(gap);
|
|
22
|
-
}
|
|
23
|
-
return { live, accepted: acceptedOut };
|
|
24
|
-
}
|
|
25
|
-
function secretScanningGaps(repo) {
|
|
26
|
-
const gaps = [];
|
|
27
|
-
if (repo.secretScanning !== "enabled")
|
|
28
|
-
gaps.push(`secret scanning ${repo.secretScanning} (fleet floor is enabled)`);
|
|
29
|
-
if (repo.pushProtection !== "enabled")
|
|
30
|
-
gaps.push(`push protection ${repo.pushProtection} (fleet floor is enabled)`);
|
|
31
|
-
return gaps;
|
|
32
|
-
}
|
|
33
|
-
function renovateGaps(health, now) {
|
|
34
|
-
if (!health.present) return ["no renovate workflow (dependency updates never run here)"];
|
|
35
|
-
if (health.state !== "active")
|
|
36
|
-
return [
|
|
37
|
-
// A disabled workflow REJECTS workflow_dispatch (HTTP 403) — it must be
|
|
38
|
-
// re-enabled first; "just dispatch it" is a dead-end runbook line here.
|
|
39
|
-
`renovate workflow is ${health.state} (re-enable it: \`gh workflow enable renovate.yml -R <repo>\`, then dispatch once)`
|
|
40
|
-
];
|
|
41
|
-
if (health.lastSuccessAt === null)
|
|
42
|
-
return [
|
|
43
|
-
"renovate workflow has never succeeded (dispatch it once; if this gap returns, the schedule never registered \u2014 push a commit touching the workflow file)"
|
|
44
|
-
];
|
|
45
|
-
const succeededAt = Date.parse(health.lastSuccessAt);
|
|
46
|
-
if (Number.isNaN(succeededAt))
|
|
47
|
-
return [`renovate last-success timestamp unreadable: ${health.lastSuccessAt}`];
|
|
48
|
-
const days = (now.getTime() - succeededAt) / 864e5;
|
|
49
|
-
if (days > RENOVATE_STALE_AFTER_DAYS)
|
|
50
|
-
return [`renovate last succeeded ${Math.floor(days)}d ago (cron is twice daily)`];
|
|
51
|
-
return [];
|
|
52
|
-
}
|
|
53
|
-
function renovateBlockedGaps(blocked) {
|
|
54
|
-
const orphaned = blocked.filter((b) => b.orphaned);
|
|
55
|
-
if (orphaned.length === 0) return [];
|
|
56
|
-
return [
|
|
57
|
-
`renovate is BLOCKED on ${orphaned.length} branch(es) \u2014 ${orphaned.map((b) => b.branch).join(", ")} \u2014 it runs and succeeds but refuses to update them, so every dependency grouped there is frozen. Usual cause: the branch tip was authored by a retired bot identity, which reads to Renovate as a human edit. Fix: confirm the tip author is a bot, then delete the branch and let Renovate recreate it (\`gh api -X DELETE repos/<repo>/git/refs/heads/<branch>\`).`
|
|
58
|
-
];
|
|
59
|
-
}
|
|
60
|
-
function dashboardVocabularyGaps(dash) {
|
|
61
|
-
if (!dash.present || dash.unknownSections.length === 0) return [];
|
|
62
|
-
return [
|
|
63
|
-
`renovate dashboard has ${dash.unknownSections.length} section(s) this audit does not recognise \u2014 ${dash.unknownSections.join(", ")}. Nothing is necessarily wrong with the repo, but the blocked-branch check reads Renovate's own headings, so drift there means it can no longer be trusted. Fix: reconcile KNOWN_DASHBOARD_SECTIONS in src/github/gh.ts against Renovate's dependency-dashboard.ts.`
|
|
64
|
-
];
|
|
65
|
-
}
|
|
66
|
-
var BLOCKED_STALE_DAYS = 5;
|
|
67
|
-
async function resolveBlockedBranches(repo, dash, deps, now) {
|
|
68
|
-
if (!dash.present) return [];
|
|
69
|
-
const states = [];
|
|
70
|
-
for (const branch of dash.blockedBranches) {
|
|
71
|
-
const tip = await deps.branchTip(repo, branch);
|
|
72
|
-
if (!tip) continue;
|
|
73
|
-
const ageDays = (now.getTime() - new Date(tip.committedAt).getTime()) / 864e5;
|
|
74
|
-
states.push({ branch, orphaned: tip.authorIsMachine || ageDays > BLOCKED_STALE_DAYS });
|
|
75
|
-
}
|
|
76
|
-
return states;
|
|
77
|
-
}
|
|
78
|
-
async function collectProtectionCoverage(org, deps, now = /* @__PURE__ */ new Date(), accepted = ACCEPTED_GAPS) {
|
|
79
|
-
const rows = [];
|
|
80
|
-
for (const r of await deps.listOrgRepos(org)) {
|
|
81
|
-
const repo = `${org}/${r.name}`;
|
|
82
|
-
if (r.archived || r.visibility !== "public") {
|
|
83
|
-
rows.push({
|
|
84
|
-
repo,
|
|
85
|
-
status: "skipped",
|
|
86
|
-
detail: r.archived ? "archived" : `${r.visibility} (rulesets need a paid plan)`
|
|
87
|
-
});
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
const gaps = [];
|
|
92
|
-
let coveredDetail = "";
|
|
93
|
-
const rulesets = await deps.listRepoRulesets(repo);
|
|
94
|
-
if (rulesets.length === 0) {
|
|
95
|
-
gaps.push("no repo rulesets at all");
|
|
96
|
-
} else {
|
|
97
|
-
const judged = await Promise.all(
|
|
98
|
-
rulesets.map(async (rs) => {
|
|
99
|
-
const full = await deps.getRuleset(repo, rs.id);
|
|
100
|
-
return { name: rs.name, full, gaps: rulesetGaps(full, null) };
|
|
101
|
-
})
|
|
102
|
-
);
|
|
103
|
-
const covering = judged.find((j) => j.gaps.length === 0);
|
|
104
|
-
if (covering) {
|
|
105
|
-
const ciGated = (covering.full.rules ?? []).some(
|
|
106
|
-
(rule) => rule.type === "required_status_checks"
|
|
107
|
-
);
|
|
108
|
-
coveredDetail = `"${covering.name}"${ciGated ? "" : " \u2014 NO CI gate (refs rules only)"}`;
|
|
109
|
-
} else {
|
|
110
|
-
gaps.push(judged.map((j) => `"${j.name}": ${j.gaps.join("; ")}`).join(" | "));
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
gaps.push(...secretScanningGaps(r));
|
|
114
|
-
gaps.push(...renovateGaps(await deps.workflowHealth(repo, RENOVATE_WORKFLOW_FILE), now));
|
|
115
|
-
const dash = await deps.dependencyDashboard(repo);
|
|
116
|
-
gaps.push(...dashboardVocabularyGaps(dash));
|
|
117
|
-
gaps.push(...renovateBlockedGaps(await resolveBlockedBranches(repo, dash, deps, now)));
|
|
118
|
-
const { live, accepted: acked } = partitionAcceptedGaps(repo, gaps, now, accepted);
|
|
119
|
-
if (live.length > 0) {
|
|
120
|
-
rows.push({ repo, status: "gap", detail: [...live, ...acked].join(" | ") });
|
|
121
|
-
} else if (acked.length > 0) {
|
|
122
|
-
rows.push({ repo, status: "skipped", detail: acked.join(" | ") });
|
|
123
|
-
} else {
|
|
124
|
-
rows.push({ repo, status: "covered", detail: coveredDetail });
|
|
125
|
-
}
|
|
126
|
-
} catch (e) {
|
|
127
|
-
rows.push({
|
|
128
|
-
repo,
|
|
129
|
-
status: "gap",
|
|
130
|
-
detail: `probe failed: ${e instanceof Error ? e.message : String(e)}`
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return rows;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// src/cli/commands/protection-audit.ts
|
|
138
|
-
function protectionAuditExitCode(gaps) {
|
|
139
|
-
return gaps > 0 ? 1 : 0;
|
|
140
|
-
}
|
|
141
|
-
async function runProtectionAuditCommand(opts, depsOverride) {
|
|
142
|
-
const org = opts.org?.trim();
|
|
143
|
-
if (!org) {
|
|
144
|
-
return { output: "protection-audit requires --org <org>", code: 2 };
|
|
145
|
-
}
|
|
146
|
-
const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
|
|
147
|
-
if (!depsOverride && !token) {
|
|
148
|
-
return {
|
|
149
|
-
output: "protection-audit skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.",
|
|
150
|
-
code: 0
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
const deps = depsOverride ?? makeGitHub({ token });
|
|
154
|
-
const rows = await collectProtectionCoverage(org, deps);
|
|
155
|
-
const gaps = rows.filter((r) => r.status === "gap");
|
|
156
|
-
const covered = rows.filter((r) => r.status === "covered");
|
|
157
|
-
const skipped = rows.filter((r) => r.status === "skipped");
|
|
158
|
-
const lines = rows.map((r) => `${r.status.toUpperCase().padEnd(7)} ${r.repo} \u2014 ${r.detail}`);
|
|
159
|
-
lines.push(
|
|
160
|
-
`PROTECTION_AUDIT gaps=${gaps.length} covered=${covered.length} skipped=${skipped.length} total=${rows.length}`
|
|
161
|
-
);
|
|
162
|
-
return { output: lines.join("\n"), code: protectionAuditExitCode(gaps.length) };
|
|
163
|
-
}
|
|
164
|
-
export {
|
|
165
|
-
protectionAuditExitCode,
|
|
166
|
-
runProtectionAuditCommand
|
|
167
|
-
};
|
|
168
|
-
//# sourceMappingURL=protection-audit-SJO53MEQ.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/audits/protection-coverage.ts","../src/cli/commands/protection-audit.ts"],"sourcesContent":["import { rulesetGaps, type ExistingRuleset } from \"../github/rulesets.js\";\nimport type { BranchTip, DependencyDashboard, WorkflowHealth } from \"../github/gh.js\";\n\n/**\n * Org-wide protection-coverage sweep (spec:\n * docs/superpowers/specs/2026-08-02-ruleset-self-healing-design.md).\n *\n * The `self-updating` recipe HEALS fleet sites, but it runs only when someone\n * runs it, and only over Airtable-listed sites — which is exactly how the\n * `.github` repo (no Airtable row) sat with zero protection until 2026-08-01.\n * This sweep is the layer that would have caught that on its own: it\n * enumerates the org FROM THE GITHUB API — never a hand-typed or\n * Airtable-scoped list, both of which have already produced false \"all\n * clear\"s — and judges every public repo's protection by SHAPE, not by name,\n * so a differently-named but sound ruleset (reddoor-maintenance's \"Main\n * Protection\") counts.\n *\n * Since 2026-08-02 the same sweep also verifies three more posture surfaces\n * per public repo, because each regresses silently and none has any other\n * watcher:\n * - secret scanning + push protection stay ENABLED (a repo transferred in,\n * or an org-default flip, arrives with them off);\n * - the renovate workflow is registered, active, and has actually RUN\n * recently. GitHub auto-disables schedules after 60 quiet days, and\n * schedule triggers in template-cloned files may never register at all —\n * either way a fully-quiet repo stops receiving updates with zero signal\n * (the 2026-08-02 sweep found two never-run and two 5-days-stale repos on\n * its first pass);\n * - renovate is not only alive but ALLOWED TO ACT — a workflow can run green\n * forever while Renovate has quietly stopped managing a branch, which froze\n * updates on nine repos for a week in August 2026 (see\n * renovateBlockedGaps). Liveness cannot see this; only the outcome can.\n */\nexport type ProtectionCoverageRow = {\n repo: string; // owner/repo\n status: \"covered\" | \"gap\" | \"skipped\";\n /** Human-readable: covering ruleset + whether it gates on CI, the specific\n * gaps, or why the repo was skipped. */\n detail: string;\n};\n\n/** The reads the sweep needs — a subset of the GitHub factory, injected so the\n * sweep is pure + testable (same pattern as collectGitHubSignals). */\nexport type ProtectionCoverageDeps = {\n listOrgRepos: (org: string) => Promise<\n Array<{\n name: string;\n visibility: string;\n archived: boolean;\n secretScanning: string;\n pushProtection: string;\n }>\n >;\n listRepoRulesets: (repo: string) => Promise<Array<{ id: number; name: string }>>;\n getRuleset: (repo: string, id: number) => Promise<ExistingRuleset>;\n workflowHealth: (repo: string, filename: string) => Promise<WorkflowHealth>;\n dependencyDashboard: (repo: string) => Promise<DependencyDashboard>;\n branchTip: (repo: string, branch: string) => Promise<BranchTip | null>;\n};\n\nexport const RENOVATE_WORKFLOW_FILE = \"renovate.yml\";\n\n/** Cron is twice daily, so 3 days without a SUCCESSFUL run = 6+ consecutive\n * missed-or-failing runs — dead scheduling, dead credential, or broken\n * config, never jitter. (Success, not run-existence: a revoked App key still\n * creates a fresh failing run every tick.) */\nexport const RENOVATE_STALE_AFTER_DAYS = 3;\n\n/**\n * Operator-accepted gaps — the protection-audit analogue of the cockpit's\n * accepted-Watch acks: a KNOWN condition pending a real decision must not\n * comment the tracking issue every night, or the channel is tuned out by the\n * time a genuinely new gap lands in it. Constraints that keep this honest:\n * every entry is PR-reviewed, names ONE repo + ONE surface (never a blanket\n * mute), states the pending decision, and EXPIRES — past `until` the gap\n * returns on its own, so an acceptance can never quietly become permanent.\n */\nexport type AcceptedGap = {\n repo: string; // owner/repo\n /** Matched as a prefix of the gap detail line — surface-scoped, so an\n * accepted renovate gap never swallows a ruleset or secret-scanning gap. */\n detailPrefix: string;\n reason: string;\n until: string; // ISO date; exclusive — the gap is live again ON this date\n};\n\n// Currently EMPTY — the only two entries (no-renovate on reddoor-maintenance\n// and .github, pending an operator supply-chain call) were retired 2026-08-02\n// when the operator approved planting Renovate on both. Add entries only for\n// a named pending decision, never as a mute.\nexport const ACCEPTED_GAPS: AcceptedGap[] = [];\n\n/** Split gaps into live vs accepted-for-now. Acceptance is per-surface (detail\n * prefix) and expires: an entry past `until` accepts nothing. */\nexport function partitionAcceptedGaps(\n repo: string,\n gaps: string[],\n now: Date,\n accepted: AcceptedGap[] = ACCEPTED_GAPS,\n): { live: string[]; accepted: string[] } {\n const live: string[] = [];\n const acceptedOut: string[] = [];\n for (const gap of gaps) {\n const match = accepted.find(\n (a) => a.repo === repo && gap.startsWith(a.detailPrefix) && now < new Date(a.until),\n );\n if (match) acceptedOut.push(`${gap} [accepted until ${match.until}: ${match.reason}]`);\n else live.push(gap);\n }\n return { live, accepted: acceptedOut };\n}\n\n/** Public repos only — secret scanning is plan-gated off on private Free\n * repos, and those are skipped before this is consulted. \"unavailable\" means\n * the listing couldn't read security_and_analysis (token lacks admin read),\n * which must read as unverified, not as fine. */\nexport function secretScanningGaps(repo: {\n secretScanning: string;\n pushProtection: string;\n}): string[] {\n const gaps: string[] = [];\n if (repo.secretScanning !== \"enabled\")\n gaps.push(`secret scanning ${repo.secretScanning} (fleet floor is enabled)`);\n if (repo.pushProtection !== \"enabled\")\n gaps.push(`push protection ${repo.pushProtection} (fleet floor is enabled)`);\n return gaps;\n}\n\n/** Renovate liveness verdict — judged on the last SUCCESSFUL run. The cron IS\n * the merge cadence (platform auto-merge is off fleet-wide), so a workflow\n * that isn't succeeding means the repo silently stops updating — the exact\n * class the 2026-07-26 incident review called \"zero-run blindness\". */\nexport function renovateGaps(health: WorkflowHealth, now: Date): string[] {\n if (!health.present) return [\"no renovate workflow (dependency updates never run here)\"];\n if (health.state !== \"active\")\n return [\n // A disabled workflow REJECTS workflow_dispatch (HTTP 403) — it must be\n // re-enabled first; \"just dispatch it\" is a dead-end runbook line here.\n `renovate workflow is ${health.state} (re-enable it: \\`gh workflow enable renovate.yml -R <repo>\\`, then dispatch once)`,\n ];\n if (health.lastSuccessAt === null)\n return [\n \"renovate workflow has never succeeded (dispatch it once; if this gap returns, the schedule never registered — push a commit touching the workflow file)\",\n ];\n const succeededAt = Date.parse(health.lastSuccessAt);\n if (Number.isNaN(succeededAt))\n return [`renovate last-success timestamp unreadable: ${health.lastSuccessAt}`];\n const days = (now.getTime() - succeededAt) / 86_400_000;\n if (days > RENOVATE_STALE_AFTER_DAYS)\n return [`renovate last succeeded ${Math.floor(days)}d ago (cron is twice daily)`];\n return [];\n}\n\n/**\n * Renovate EFFECTIVENESS, which is a different question from liveness.\n *\n * `renovateGaps` above asks \"did the workflow run and succeed?\" — and on\n * 2026-08-03 nine repos answered YES to that while shipping zero updates for a\n * week. The `reddoor-renovate` App migration (2026-08-02) left each repo\n * holding a `renovate/all-minor-patch` branch whose tip was authored by the\n * OLD `renovate-bot` PAT identity. Renovate compares that author to its own,\n * concludes a human edited the branch, files it under \"PR Edited (Blocked) —\n * Renovate will no longer make changes\", and never touches it again. Every run\n * kept succeeding; `@reddoorla/maintenance`, `@sveltejs/kit`, `vite`,\n * `@playwright/test` and `node` were all frozen behind it; CI stayed green and\n * nothing anywhere raised a hand.\n *\n * A liveness probe structurally cannot see this: the workflow is doing its job\n * perfectly, it is simply forbidden to act. So this reads the outcome Renovate\n * itself publishes — the one place it admits to having given up.\n *\n * Absent dashboard is NOT a gap: `dependencyDashboard` is a config a repo may\n * legitimately disable, and the liveness probe already owns the dead-Renovate\n * case.\n *\n * Nor is every blocked branch a gap — see resolveBlockedBranches, which is\n * where the orphan/in-flight distinction is made. Both exclusions serve the\n * same property: this surface reports only what it can stand behind, which is\n * what decides whether an alarm channel still gets read a month from now.\n */\nexport function renovateBlockedGaps(blocked: BlockedBranchState[]): string[] {\n const orphaned = blocked.filter((b) => b.orphaned);\n if (orphaned.length === 0) return [];\n return [\n `renovate is BLOCKED on ${orphaned.length} branch(es) — ` +\n `${orphaned.map((b) => b.branch).join(\", \")} — it runs and succeeds but refuses to ` +\n `update them, so every dependency grouped there is frozen. Usual cause: the branch ` +\n `tip was authored by a retired bot identity, which reads to Renovate as a human ` +\n `edit. Fix: confirm the tip author is a bot, then delete the branch and let ` +\n `Renovate recreate it (\\`gh api -X DELETE repos/<repo>/git/refs/heads/<branch>\\`).`,\n ];\n}\n\n/**\n * A dashboard whose section vocabulary we do not share. Reported separately\n * from blocked branches because it is a different claim: not \"Renovate is\n * stuck\" but \"this audit can no longer tell whether Renovate is stuck.\" The\n * blocked heading was renamed once already (Edited/Blocked -> PR Edited\n * (Blocked) in Renovate 43), and the fleet inherits its Renovate major from\n * whatever renovatebot/github-action bakes in — so the next rename arrives via\n * an ordinary dependency PR that Renovate merges in-run, with nobody reading\n * dashboard wording. Without this, that rename would flip every repo to\n * covered on the same night and the surface would go permanently blind.\n */\nexport function dashboardVocabularyGaps(dash: DependencyDashboard): string[] {\n if (!dash.present || dash.unknownSections.length === 0) return [];\n return [\n `renovate dashboard has ${dash.unknownSections.length} section(s) this audit does ` +\n `not recognise — ${dash.unknownSections.join(\", \")}. Nothing is necessarily wrong ` +\n `with the repo, but the blocked-branch check reads Renovate's own headings, so ` +\n `drift there means it can no longer be trusted. Fix: reconcile ` +\n `KNOWN_DASHBOARD_SECTIONS in src/github/gh.ts against Renovate's ` +\n `dependency-dashboard.ts.`,\n ];\n}\n\n/** How long a human-owned blocked branch may sit before it stops reading as\n * in-flight work. Sized to clear a weekend plus slack: the fleet's own\n * human-edited Renovate PRs merge in minutes, while the branches that froze\n * nine repos sat for a week. */\nexport const BLOCKED_STALE_DAYS = 5;\n\nexport type BlockedBranchState = { branch: string; orphaned: boolean };\n\n/**\n * Decide which blocked branches are actually a posture problem.\n *\n * `PR Edited (Blocked)` is not by itself a fault — it is also Renovate's\n * designed way of saying \"a human owns this branch now\", and pushing a commit\n * onto an open Renovate PR is routine fleet practice (16 such PRs across 14\n * repos on a single day). Alarming on those would fire the nightly issue for\n * perfectly healthy in-flight work, and would attach a remediation that says\n * to delete the branch — i.e. the human's commits.\n *\n * A branch counts as ORPHANED, and therefore a gap, when either:\n * - its tip was authored by a machine, so no human owns it and nothing will\n * ever clear it on its own; or\n * - it has sat untouched past BLOCKED_STALE_DAYS, whoever authored it —\n * because Renovate updates a branch it is managing, so tip age IS time\n * spent frozen.\n *\n * The authorship test is the fast path and the staleness test is the backstop:\n * if machine-identity detection ever drifts, detection degrades to slower, not\n * to blind. A branch that no longer exists is not a gap — that is a dashboard\n * Renovate has not rewritten yet.\n */\nexport async function resolveBlockedBranches(\n repo: string,\n dash: DependencyDashboard,\n deps: Pick<ProtectionCoverageDeps, \"branchTip\">,\n now: Date,\n): Promise<BlockedBranchState[]> {\n if (!dash.present) return [];\n const states: BlockedBranchState[] = [];\n for (const branch of dash.blockedBranches) {\n const tip = await deps.branchTip(repo, branch);\n if (!tip) continue;\n const ageDays = (now.getTime() - new Date(tip.committedAt).getTime()) / 86_400_000;\n states.push({ branch, orphaned: tip.authorIsMachine || ageDays > BLOCKED_STALE_DAYS });\n }\n return states;\n}\n\n/**\n * One row per org repo. Coverage = SOME repo-sourced ruleset with zero\n * stage-1 gaps (active, empty bypass, default branch covered, deletion +\n * non_fast_forward + pull_request) AND secret scanning enabled AND a live\n * renovate workflow. Whether a covering ruleset also gates on CI is reported\n * as detail, not judged: each repo's required context differs (and\n * reddoor-maintenance deliberately has none pending release-path review), so\n * the CI-gate invariant belongs to the per-site heal, which has the evidence\n * to require the RIGHT context safely.\n *\n * Private/archived repos are skipped (rulesets on private repos need a paid\n * plan; archived repos are read-only). A repo whose probe THROWS is a `gap`,\n * not a skip — \"couldn't verify\" reading as \"fine\" is the silent-green\n * failure mode this sweep exists to kill, and the tracking issue auto-closes\n * on the next clean pass, so a transient API blip costs one issue-comment.\n */\nexport async function collectProtectionCoverage(\n org: string,\n deps: ProtectionCoverageDeps,\n now: Date = new Date(),\n accepted: AcceptedGap[] = ACCEPTED_GAPS,\n): Promise<ProtectionCoverageRow[]> {\n const rows: ProtectionCoverageRow[] = [];\n for (const r of await deps.listOrgRepos(org)) {\n const repo = `${org}/${r.name}`;\n if (r.archived || r.visibility !== \"public\") {\n rows.push({\n repo,\n status: \"skipped\",\n detail: r.archived ? \"archived\" : `${r.visibility} (rulesets need a paid plan)`,\n });\n continue;\n }\n try {\n const gaps: string[] = [];\n let coveredDetail = \"\";\n const rulesets = await deps.listRepoRulesets(repo);\n if (rulesets.length === 0) {\n gaps.push(\"no repo rulesets at all\");\n } else {\n const judged = await Promise.all(\n rulesets.map(async (rs) => {\n const full = await deps.getRuleset(repo, rs.id);\n return { name: rs.name, full, gaps: rulesetGaps(full, null) };\n }),\n );\n const covering = judged.find((j) => j.gaps.length === 0);\n if (covering) {\n const ciGated = (covering.full.rules ?? []).some(\n (rule) => rule.type === \"required_status_checks\",\n );\n coveredDetail = `\"${covering.name}\"${ciGated ? \"\" : \" — NO CI gate (refs rules only)\"}`;\n } else {\n gaps.push(judged.map((j) => `\"${j.name}\": ${j.gaps.join(\"; \")}`).join(\" | \"));\n }\n }\n gaps.push(...secretScanningGaps(r));\n gaps.push(...renovateGaps(await deps.workflowHealth(repo, RENOVATE_WORKFLOW_FILE), now));\n const dash = await deps.dependencyDashboard(repo);\n gaps.push(...dashboardVocabularyGaps(dash));\n gaps.push(...renovateBlockedGaps(await resolveBlockedBranches(repo, dash, deps, now)));\n const { live, accepted: acked } = partitionAcceptedGaps(repo, gaps, now, accepted);\n if (live.length > 0) {\n // Accepted annotations still ride along for context, but only LIVE\n // gaps make the row a gap (and thus the sweep exit 1).\n rows.push({ repo, status: \"gap\", detail: [...live, ...acked].join(\" | \") });\n } else if (acked.length > 0) {\n // Judged, found wanting, deliberately acked — reported as skipped\n // (visible every night with the reason + expiry), never as covered.\n rows.push({ repo, status: \"skipped\", detail: acked.join(\" | \") });\n } else {\n rows.push({ repo, status: \"covered\", detail: coveredDetail });\n }\n } catch (e) {\n rows.push({\n repo,\n status: \"gap\",\n detail: `probe failed: ${e instanceof Error ? e.message : String(e)}`,\n });\n }\n }\n return rows;\n}\n","import {\n collectProtectionCoverage,\n type ProtectionCoverageDeps,\n} from \"../../audits/protection-coverage.js\";\nimport { makeGitHub } from \"../../github/gh.js\";\n\n/** Exit 1 on ANY gap: unlike the fleet write sweeps (majority-rule), a single\n * unprotected public repo is a real hole, not a flake to tolerate. */\nexport function protectionAuditExitCode(gaps: number): number {\n return gaps > 0 ? 1 : 0;\n}\n\n/** `protection-audit --org <org>`: verify every public repo in the org carries\n * a sound branch ruleset, live secret scanning + push protection, and a\n * renovate workflow that actually runs (see collectProtectionCoverage for the\n * verdicts). Emits one line per repo + a machine-readable PROTECTION_AUDIT\n * summary the nightly workflow gates its tracking issue on. A missing fleet\n * token is a clean skip (local runs), matching github-signals. */\nexport async function runProtectionAuditCommand(\n opts: { org?: string | undefined },\n // Injected in tests; defaults to the real gh-CLI-backed factory.\n depsOverride?: ProtectionCoverageDeps,\n): Promise<{ output: string; code: number }> {\n const org = opts.org?.trim();\n if (!org) {\n return { output: \"protection-audit requires --org <org>\", code: 2 };\n }\n const token = process.env.RENOVATE_TOKEN?.trim() || process.env.GH_TOKEN?.trim();\n if (!depsOverride && !token) {\n return {\n output: \"protection-audit skipped: no RENOVATE_TOKEN/GH_TOKEN (fleet read) configured.\",\n code: 0,\n };\n }\n const deps = depsOverride ?? makeGitHub({ token: token! });\n\n const rows = await collectProtectionCoverage(org, deps);\n const gaps = rows.filter((r) => r.status === \"gap\");\n const covered = rows.filter((r) => r.status === \"covered\");\n const skipped = rows.filter((r) => r.status === \"skipped\");\n\n const lines = rows.map((r) => `${r.status.toUpperCase().padEnd(7)} ${r.repo} — ${r.detail}`);\n lines.push(\n `PROTECTION_AUDIT gaps=${gaps.length} covered=${covered.length} skipped=${skipped.length} total=${rows.length}`,\n );\n return { output: lines.join(\"\\n\"), code: protectionAuditExitCode(gaps.length) };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/prospect/render.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { ANALYZE_SKIPPED, PROBES_SKIPPED } from \"./pipeline.js\";\nimport { resolveBusinessName } from \"./probes.js\";\nimport type {\n AnalyzeResult,\n ChecksResult,\n Fix,\n LighthouseScores,\n ProbeAnswer,\n ProbesResult,\n ProspectAuditResult,\n StageResult,\n} from \"./types.js\";\n\nconst RED = \"#d71920\";\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\nconst KIND_LABEL: Record<ProbeAnswer[\"kind\"], string> = {\n branded: \"Branded — asked about the business by name\",\n category: \"Category — the questions a buyer would actually type\",\n competitor: \"Competitor — head-to-head comparisons\",\n};\nconst KIND_ORDER: ProbeAnswer[\"kind\"][] = [\"category\", \"branded\", \"competitor\"];\n\n/** Terse product mapping so a recipient who has never seen a crawler's raw\n * user-agent string can place it — factual labelling, not a voice change. */\nconst AI_AGENT_LABELS: Record<string, string> = {\n GPTBot: \"ChatGPT\",\n \"OAI-SearchBot\": \"ChatGPT's search results\",\n ClaudeBot: \"Claude\",\n PerplexityBot: \"Perplexity\",\n \"Google-Extended\": \"Gemini and Google's AI Overviews\",\n CCBot: \"Common Crawl, used to train many AI models\",\n};\n\nfunction describeAgent(agent: string): string {\n const label = AI_AGENT_LABELS[agent];\n return label ? `${agent} (feeds ${label})` : agent;\n}\n\n/** Every genuine stage failure collapses to this one phrase — the real error\n * (status codes, retry counts, timeouts — operator vocabulary) stays in the\n * persisted JSON and CLI output for operators, but never reaches a stranger\n * who would read it as broken software rather than a diagnostic. */\nconst STAGE_FAILED_MESSAGE = \"we could not complete this part of the analysis\";\n\nconst STYLES = `\n :root { color-scheme: light; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #faf8f5; color: #1a1a1a;\n font: 16px/1.6 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif; }\n .wrap { max-width: 860px; margin: 0 auto; padding: 48px 24px 80px; }\n h1, h2, h3 { font-family: Besley, Georgia, \"Times New Roman\", serif; font-weight: 600; line-height: 1.2; }\n h1 { font-size: 40px; margin: 0 0 8px; }\n h2 { font-size: 26px; margin: 48px 0 12px; border-top: 2px solid #e6e1da; padding-top: 24px; }\n h3 { font-size: 18px; margin: 24px 0 8px; }\n .lede { color: #57544f; margin: 0 0 8px; }\n .scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 32px 0; }\n .score { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; }\n .score .n { font-family: Besley, Georgia, serif; font-size: 38px; line-height: 1; color: ${RED}; }\n .score .n.na { font-size: 18px; color: #8a857e; }\n .score .l { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #57544f; margin-top: 8px; }\n .score .hint { font-size: 12px; color: #8a857e; margin-top: 4px; }\n .card { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; margin: 12px 0; }\n .q { font-weight: 600; }\n .tag { display: inline-block; font-size: 12px; letter-spacing: .06em; text-transform: uppercase;\n border-radius: 999px; padding: 2px 10px; border: 1px solid currentColor; }\n .yes { color: #14663c; } .partial { color: #8a6d00; } .no { color: ${RED}; }\n .unknown { color: #8a857e; border-style: dashed; }\n ul { padding-left: 20px; } li { margin: 6px 0; }\n .muted { color: #8a857e; }\n .cta { margin-top: 56px; background: ${RED}; color: #fff; border-radius: 8px; padding: 24px 28px; }\n .cta h2 { border: 0; padding: 0; margin: 0 0 8px; color: #fff; }\n .cta a { color: #fff; }\n table { width: 100%; border-collapse: collapse; margin-top: 8px; }\n td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }\n @media print {\n body { background: #fff; }\n .card, .score, .cta { break-inside: avoid; }\n /* Browsers default \"print background graphics\" OFF, which drops the red\n .cta background but keeps its white text — the only ask in the report\n would otherwise print white-on-white and vanish. Fall back to dark\n text on no background with a visible border instead of relying on the\n background for contrast. */\n .cta { background: none; color: #1a1a1a; border: 2px solid ${RED}; }\n .cta h2, .cta a { color: #1a1a1a; }\n }\n`;\n\n/** One of the THREE site-score cards. AI Visibility used to be a fourth and\n * was removed on purpose, matching the web report's control split: these\n * three measure the site and move when the site is fixed; visibility is\n * measured out in the world and reported with receipts in \"What the AI\n * engines said about you\" — a fourth card said it was the same kind of claim,\n * and ours to move. It is neither. `scores.aiVisibility` stays computed and\n * stored; it just is not a card, here or in the email or CLI summary. */\nfunction scoreCard(label: string, value: number | null, hint?: string): string {\n const n =\n value === null ? `<div class=\"n na\">Not measured</div>` : `<div class=\"n\">${value}</div>`;\n const hintHtml = hint ? `<div class=\"hint\">${escapeHtml(hint)}</div>` : \"\";\n return `<div class=\"score\">${n}<div class=\"l\">${escapeHtml(label)}</div>${hintHtml}</div>`;\n}\n\n/** Body for a stage that succeeded, or a uniform \"Not measured\" note when it didn't. */\nfunction stageBody<T>(stage: StageResult<T>, body: (data: T) => string): string {\n return stage.ok ? body(stage.data) : notMeasuredNote();\n}\n\n/** The stage's real error is deliberately not a parameter here — see\n * STAGE_FAILED_MESSAGE above. Every genuine failure reads the same\n * client-safe way, regardless of what actually broke. */\nfunction notMeasuredNote(): string {\n return `<p class=\"muted\">Not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\n/** A stage a human deliberately turned off (--no-probes, or analyze skipped\n * because checks failed upstream) must read as \"you asked us to skip this\" —\n * never as \"we tried and could not\", which is what a bare \"Not measured —\n * {error}\" would otherwise imply. Compares against the pipeline's exported\n * constants rather than a retyped string literal. */\nfunction skippableStageNote(error: string, skipConstant: string, skipMessage: string): string {\n if (error === skipConstant) {\n return `<p class=\"muted\">${escapeHtml(skipMessage)}</p>`;\n }\n return notMeasuredNote();\n}\n\n/** `new Date(iso).toLocaleDateString()` never throws on a bad timestamp — it\n * silently returns \"Invalid Date\" — so a try/catch here is dead code. Guard\n * on getTime() instead, and fall back to the raw ISO string so a corrupt\n * timestamp is at least visibly a timestamp rather than the literal words\n * \"Invalid Date\". */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\n/** The probes section leads the report, so a branded-only run (analyze\n * failed upstream and supplied no buyer questions, or every category query\n * itself failed) must not let the branded recognition line read as a\n * positive discoverability finding on its own — an engine echoing a name\n * it was just handed proves nothing about whether a buyer who never named\n * the business would find it. */\n/** `businessNameUsed` is whether the engines were actually queried with the\n * business NAME — not merely whether one existed. `resolveBusinessName` falls\n * back to the bare domain for anything it cannot use, so a name can be present\n * and still never reach a query. Without this gate, \"even with the name handed\n * to them\" is a false claim: whenever businessName was empty, and (until the\n * caller was fixed) whenever the name was rejected and the probes searched for\n * the domain instead. Never derived from the raw ProbesResult, which has no\n * way to say \"no name was available\". */\nfunction buildProbesSection(p: ProbesResult, businessNameUsed: boolean): string {\n const byKind = new Map<ProbeAnswer[\"kind\"], ProbeAnswer[]>();\n for (const a of p.answers) {\n const bucket = byKind.get(a.kind) ?? [];\n bucket.push(a);\n byKind.set(a.kind, bucket);\n }\n\n const recognition = p.brandedRecognized\n ? \"<p>When the engines were asked about the business by name, they recognized it.</p>\"\n : businessNameUsed\n ? \"<p>When the engines were asked about the business by name, they did not recognize it — a real citation of the site never showed up, even with the name handed to them.</p>\"\n : \"<p>The engines did not recognize the business — there was no business name available to ask them with, so this reflects a harder starting point than a named query would.</p>\";\n\n const categoryCaveat = byKind.has(\"category\")\n ? \"\"\n : `<p class=\"muted\"><strong>No buyer-question (category) query was tested here</strong> — only name-recognition. An engine echoing back a name it was just given says nothing about whether a buyer who had never heard of the business would be shown it; treat the line above as a floor, not a visibility signal.</p>`;\n\n // A degraded run says so. The score now divides by what was ASKED, so failed\n // probes push it DOWN rather than silently inflating it — which is the safe\n // direction, but only if the reader is told the run was incomplete. Silence\n // here would trade one misleading number for another.\n // Bound to a local so the narrowing survives into the template — `missing > 0`\n // says nothing to the compiler about `p.categoryProbes` itself, and a stored\n // report from before the field existed genuinely does not have it.\n const tally = p.categoryProbes;\n const missing = tally ? tally.attempted - tally.answered : 0;\n const degradedNotice =\n tally && missing > 0\n ? `<p class=\"muted\"><strong>${missing} of ${tally.attempted} buyer-question searches did not come back</strong> — the engine errored on them. They are counted as \"not found\", so the visibility figure above is a floor: the real number could be higher, not lower. Worth re-running before drawing a conclusion from it.</p>`\n : \"\";\n\n const groups = KIND_ORDER.filter((k) => byKind.has(k))\n .map((kind) => {\n const answers = byKind.get(kind) ?? [];\n const cards = answers\n .map(\n (a) => `<div class=\"card\">\n <div class=\"q\">${escapeHtml(a.engine)} · “${escapeHtml(a.query)}”</div>\n <p class=\"muted\">Asked ${escapeHtml(formatIsoDate(a.askedAt))}</p>\n <p>${escapeHtml(a.snippet)}${a.truncated ? \"…\" : \"\"}</p>\n <p class=\"muted\">${\n // The scorer's own decision, recorded on the answer — NOT\n // `domainCited || brandMentioned`, which is a looser rule. Using the\n // loose one printed \"You were named in this answer\" above a card\n // contributing zero to the score beside it. Reports persisted before\n // the field existed fall back to what they already said.\n (a.countedAsVisible ?? (a.domainCited || a.brandMentioned))\n ? \"You were named in this answer.\"\n : \"You were not named in this answer.\"\n }${a.citedDomains.length ? ` Sources the engine retrieved: ${escapeHtml(a.citedDomains.join(\", \"))}` : \"\"}</p>\n </div>`,\n )\n .join(\"\");\n return `<h3>${escapeHtml(KIND_LABEL[kind])}</h3>${cards}`;\n })\n .join(\"\");\n\n const competitors = p.competitorsSeen.length\n ? `<h3>Who the engines cited instead</h3><ul>${p.competitorsSeen\n .map((c) => `<li>${escapeHtml(c.domain)} — ${c.count} time${c.count === 1 ? \"\" : \"s\"}</li>`)\n .join(\"\")}</ul>`\n : \"\";\n\n return recognition + categoryCaveat + degradedNotice + groups + competitors;\n}\n\n/** crawlerAccessMeasured is false only when the robots.txt fetch itself\n * failed, so the blocked/allowed lists are empty out of ignorance, not\n * because we confirmed access. Saying \"every crawler can reach the site\" in\n * that case would manufacture a finding from our own missing data.\n * sitemapMeasured/llmsTxtMeasured get the same treatment as\n * crawlerAccessMeasured: a failed fetch reads as \"not measured\", never as a\n * confirmed \"missing\" — that's a claim about the prospect's site we haven't\n * earned. Item 1: none of the three \"not measured\" lines below take the raw\n * fetch error as input anymore — status codes and transport vocabulary\n * (\"503 Service Unavailable\", \"ETIMEDOUT\") are operator vocabulary, same as\n * a stage failure (see STAGE_FAILED_MESSAGE); the real error still lives in\n * the persisted JSON and CLI output, never in this client-facing page. */\nfunction buildFindabilitySection(c: ChecksResult): string {\n const accessBlock = !c.crawlerAccessMeasured\n ? `<p class=\"muted\">Crawler access: not measured — ${STAGE_FAILED_MESSAGE}.</p>`\n : c.crawlerAccess.blockedAi.length\n ? `<p><strong>Blocked AI crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedAi.map(describeAgent).join(\", \"),\n )}</p>`\n : // Says what was measured, which is robots.txt, and no more. \"Every AI\n // crawler can reach the site\" was a claim robots.txt cannot support:\n // a CDN's bot management enforces its own answer and can contradict the\n // file without the owner knowing. Confirmed on a live prospect: its\n // robots.txt blocked nothing relevant, and its CDN returned 403 to one\n // named AI crawler on every request while serving a browser and two\n // other crawlers normally. The report said they were fine.\n `<p>Nothing in your robots.txt blocks the AI crawlers we checked.</p>`;\n const classical =\n c.crawlerAccessMeasured && c.crawlerAccess.blockedClassical.length\n ? `<p><strong>Blocked search crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedClassical.join(\", \"),\n )}</p>`\n : \"\";\n const sitemapLine = c.sitemapMeasured\n ? `<li>sitemap.xml: ${c.sitemapPresent ? \"present\" : \"missing\"}</li>`\n : `<li>sitemap.xml: not measured — ${STAGE_FAILED_MESSAGE}.</li>`;\n // llms.txt is no longer listed as a finding. It used to sit here as\n // \"present\"/\"missing\" beside sitemap.xml, which put the two on equal footing\n // — and \"missing\" beside a checklist reads as a job to do. Search crawlers\n // demonstrably read a sitemap; no answer engine has documented reading\n // llms.txt to build an answer. The footnote below says that outright instead\n // of leaving a prospect to infer it, or to go and build one on our say-so.\n const llmsFootnote = `<p class=\"muted\"><strong>A note on llms.txt.</strong> You may have been told to\n add one. We check for it — this site ${\n !c.llmsTxtMeasured ? \"could not be checked\" : c.llmsTxtPresent ? \"has one\" : \"does not\"\n } — but we do not score it and we will not recommend it. It is a 2024 proposal that no\n answer engine has committed to reading, and adding one has no measured effect on whether you\n are cited. If that changes we will say so and score it then.</p>`;\n return `${accessBlock}${classical}\n <ul>\n ${sitemapLine}\n <li>Pages missing a meta description: ${c.meta.missingDescription} of ${c.meta.pageCount}</li>\n <li>Pages missing a canonical URL: ${c.meta.missingCanonical} of ${c.meta.pageCount}</li>\n <li>Pages missing share images/titles: ${c.meta.missingSocial} of ${c.meta.pageCount}</li>\n <li>Security headers missing: ${\n c.securityHeaders.missing.length ? escapeHtml(c.securityHeaders.missing.join(\", \")) : \"none\"\n }</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> additional page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no readable content at all and ${\n c.meta.pagesWithoutExtract === 1 ? \"is\" : \"are\"\n } not counted in the ${c.meta.pageCount} pages above.</li>`\n : \"\"\n }\n </ul>${llmsFootnote}`;\n}\n\n/** Lighthouse runs as its own independent pipeline stage (see pipeline.ts) —\n * it does not depend on `checks` succeeding. Rendering it off `result.checks`\n * would throw away real, measured performance/SEO/accessibility numbers\n * whenever an unrelated stage failed, so this reads `result.lighthouse`\n * directly and is rendered unconditionally, not nested inside the checks\n * degrade path. */\nfunction buildLighthouseBlock(lh: StageResult<LighthouseScores>): string {\n return lh.ok\n ? `<p class=\"muted\">Lighthouse — performance ${lh.data.performance ?? \"not measured\"},\n SEO ${lh.data.seo ?? \"not measured\"}, accessibility ${lh.data.accessibility ?? \"not measured\"}.</p>`\n : `<p class=\"muted\">Lighthouse not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\nfunction buildReadabilitySection(c: ChecksResult): string {\n const jsLine =\n c.jsDependence.avgMissing === null\n ? `<p class=\"muted\">JavaScript-dependence: not measured — no page produced a comparable raw/rendered pair.</p>`\n : `<p><strong>${Math.round(c.jsDependence.avgMissing * 100)}%</strong> of the words a visitor reads only appear after JavaScript runs.\n Most AI crawlers do not run JavaScript, so that share of your site is invisible to them.</p>`;\n return `${jsLine}\n <ul>\n <li>Structured data found: ${c.schema.typesFound.length ? escapeHtml(c.schema.typesFound.join(\", \")) : \"none\"}</li>\n <li>Expected structured data missing: ${\n c.schema.missingExpected.length ? escapeHtml(c.schema.missingExpected.join(\", \")) : \"none\"\n }</li>\n <li>Pages without a top-level heading: ${c.headings.pagesWithoutH1} of ${c.meta.pageCount}</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no extract at all — excluded from every figure above, not counted as readable or unreadable.</li>`\n : \"\"\n }\n </ul>`;\n}\n\n/** The report's whole premise is claims the recipient can go check — so the\n * one quoted passage of evidence links to the page it came from (via\n * safeUrl; a null/non-http page renders as plain escaped text, no dead\n * \"#\" anchor). `answered`/`impact`/`effort` are escaped even though a zod\n * enum constrains them upstream — the renderer should not depend on\n * another file staying in sync to stay safe. */\n/** What the verdict column says. \"unknown\" is OUR gap — the model returned no\n * verdict for that question — so it must not be printed as a finding about the\n * site, and must not look like one either. Matches the web report's labels. */\nconst ANSWERED_LABEL: Record<string, string> = {\n yes: \"yes\",\n partial: \"partial\",\n no: \"no\",\n unknown: \"not measured\",\n};\n\nfunction buildAnswersSection(a: AnalyzeResult): string {\n const rows = a.buyerQuestions\n .map((q) => {\n const answered = escapeHtml(q.answered);\n const label = escapeHtml(ANSWERED_LABEL[q.answered] ?? q.answered);\n const evidenceText = q.evidence ? escapeHtml(q.evidence) : null;\n const evidenceHref = q.evidence && q.page ? safeUrl(q.page) : null;\n // An unmeasured question has no evidence for the same reason it has no\n // verdict: we never got one back. Printing \"no passage on the site\" there\n // reports our own missing measurement as a defect of theirs.\n const evidenceCell =\n q.answered === \"unknown\"\n ? '<span class=\"muted\">we did not get a verdict on this question</span>'\n : evidenceText === null\n ? '<span class=\"muted\">no passage on the site</span>'\n : evidenceHref && evidenceHref !== \"#\"\n ? `<a href=\"${escapeHtml(evidenceHref)}\" target=\"_blank\" rel=\"noopener noreferrer\">${evidenceText}</a>`\n : evidenceText;\n return `<tr>\n <td>${escapeHtml(q.question)}</td>\n <td><span class=\"tag ${answered}\">${label}</span></td>\n <td>${evidenceCell}</td>\n </tr>`;\n })\n .join(\"\");\n return `<p>${escapeHtml(a.narrative.answers)}</p>\n <table><tr><th>What buyers ask</th><th>Answered</th><th>Evidence</th></tr>${rows}</table>`;\n}\n\nexport function renderProspectReport(result: ProspectAuditResult): string {\n const host = hostnameOf(result.url);\n // `businessName` is the searchable NAME (possibly \"\"), never the\n // description. An empty/missing name is itself a finding — fall back to\n // the hostname rather than printing nothing, or worse, printing \"\" as if\n // it were a verified fact about the business.\n const name = result.businessName && result.businessName.trim() ? result.businessName : host;\n const date = formatIsoDate(result.generatedAt);\n\n const findabilitySection = stageBody(result.checks, buildFindabilitySection);\n const lighthouseBlock = buildLighthouseBlock(result.lighthouse);\n const readabilitySection = stageBody(result.checks, buildReadabilitySection);\n\n // A real business name was resolved and handed to the engines only when\n // `result.businessName` is non-empty — the same value the pipeline\n // actually queried with (see pipeline.ts). Used to gate the probes\n // section's \"even with the name handed to them\" phrasing (Fix 6).\n // Whether the engines were actually queried with the NAME, which is not the\n // same as whether a name existed. resolveBusinessName falls back to the bare\n // domain for anything it cannot use, and this line used to read the\n // un-resolved value — so a report could claim the engines were given the name\n // \"even with the name handed to them\" while the probes had in fact searched\n // for \"stlouisroofing.com\". Resolve it the same way probes.ts does.\n const businessNameUsed = Boolean(\n result.businessName &&\n result.businessName.trim() &&\n resolveBusinessName(result.businessName, result.url) === result.businessName.trim(),\n );\n\n const fixes = result.analyze.ok\n ? (() => {\n const sorted = [...result.analyze.data.fixes].sort(\n (x, y) => IMPACT_ORDER[x.impact] - IMPACT_ORDER[y.impact],\n );\n if (sorted.length === 0) {\n // A genuinely well-optimized site: a bare empty list reads as a\n // rendering bug, not a compliment. Say so directly, and keep this\n // section as a pitch — it's the one the salesperson is sending\n // the report to make.\n return `<p>Nothing here reads as urgent — the essentials are already in place.\n Reddoor can still help push this further: tightening structured data, monitoring\n how the answer engines describe you, and keeping pace as they change.</p>`;\n }\n return `<ol>${sorted\n .map((f) => {\n const impact = escapeHtml(f.impact);\n const effort = escapeHtml(f.effort);\n return `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)}\n <span class=\"muted\">(${impact} impact, ${effort} effort)</span></li>`;\n })\n .join(\"\")}</ol>`;\n })()\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there was nothing to build fixes from.\",\n );\n\n const probesSectionFinal = result.probes.ok\n ? buildProbesSection(result.probes.data, businessNameUsed)\n : skippableStageNote(\n result.probes.error,\n PROBES_SKIPPED,\n \"This audit did not run the AI-visibility probes.\",\n );\n\n const answersSectionFinal = result.analyze.ok\n ? buildAnswersSection(result.analyze.data)\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there were no buyer questions to check.\",\n );\n\n const description = result.analyze.ok ? result.analyze.data.business : null;\n const narrative = result.analyze.ok\n ? `<p class=\"lede\">${escapeHtml(description ?? \"\")}</p>\n <p class=\"lede\">${escapeHtml(result.analyze.data.narrative.findability)}\n ${escapeHtml(result.analyze.data.narrative.readability)}</p>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<meta name=\"robots\" content=\"noindex\" />\n<title>Can AI find ${escapeHtml(name)}? — Reddoor audit</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n<link href=\"https://fonts.googleapis.com/css2?family=Besley:wght@400;600&display=swap\" rel=\"stylesheet\" />\n<style>${STYLES}</style>\n</head>\n<body>\n<div class=\"wrap\">\n <h1>Can AI and Google actually find ${escapeHtml(name)}?</h1>\n <p class=\"lede\"><a href=\"${escapeHtml(safeUrl(result.url))}\" target=\"_blank\" rel=\"noopener noreferrer\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(date)} by Reddoor Creative</p>\n ${narrative}\n\n <p class=\"muted\">Each score below runs 0–100 — read it as a score, not a percentage.</p>\n <div class=\"scores\">\n ${scoreCard(\"Findability\", result.scores.findability, \"How easily AI and search crawlers can find and reach your site — crawl rules, sitemap, key metadata\")}\n ${scoreCard(\"Readability\", result.scores.readability, \"How much of your site's content those crawlers can actually read once they're in — most don't run JavaScript\")}\n ${scoreCard(\"Answers\", result.scores.answers, \"How many buyer questions your site's own content answers — separate from what the AI engines say back\")}\n </div>\n\n <h2>What the AI engines said about you</h2>\n ${probesSectionFinal}\n\n <h2>What the crawlers can reach</h2>\n ${findabilitySection}\n ${lighthouseBlock}\n\n <h2>What the crawlers can read</h2>\n ${readabilitySection}\n\n <h2>The questions your buyers ask</h2>\n ${answersSectionFinal}\n\n <h2>What to fix first</h2>\n ${fixes}\n\n <div class=\"cta\">\n <h2>Want this fixed?</h2>\n <p>Reddoor Creative rebuilds sites so answer engines can read, quote and recommend them.\n Reply to the email this link came from, or start at\n <a href=\"https://reddoorla.com/\">reddoorla.com</a>.</p>\n </div>\n</div>\n</body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/deadletter.ts","../src/forms/replay.ts"],"sourcesContent":["import type { Db } from \"./client.js\";\nimport type { TurnstileVerification } from \"../forms/turnstile.js\";\n\n/** One captured-but-unresolved lead: the site lookup threw before the normal\n * pipeline could run (Phase 0 of #539 — persist before enrich). */\nexport type DeadLetterRow = {\n id: string;\n siteSlug: string;\n /** The raw wire payload, exactly as the handler received it. */\n payload: unknown;\n /** The Turnstile verification computed at receipt — tokens expire in 300s,\n * so replay reuses this answer rather than re-verifying. */\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: string;\n};\n\nexport type DeadLetterInput = {\n siteSlug: string;\n payload: unknown;\n turnstile: TurnstileVerification;\n error: string;\n receivedAt: Date;\n};\n\n/** Opaque, collision-free id (mirrors newSubmissionId). */\nexport function newDeadLetterId(): string {\n return `dl_${crypto.randomUUID()}`;\n}\n\n/** Persist a lead the normal pipeline could not place. This is the LAST writer\n * on the ingest path — if it throws too, both stores are down and the caller's\n * 502 is honest. */\nexport async function createDeadLetter(db: Db, input: DeadLetterInput): Promise<{ id: string }> {\n const id = newDeadLetterId();\n await db\n .insertInto(\"submission_deadletter\")\n .values({\n id,\n site_slug: input.siteSlug,\n payload: JSON.stringify(input.payload),\n turnstile: JSON.stringify(input.turnstile),\n error: input.error,\n received_at: input.receivedAt.toISOString(),\n replayed_at: null,\n replay_outcome: null,\n replay_submission_id: null,\n })\n .execute();\n return { id };\n}\n\n/** Rows not yet replayed, oldest first — replay preserves arrival order so the\n * duplicate/velocity signals see submissions in the order they happened. */\nexport async function listUnreplayedDeadLetters(db: Db): Promise<DeadLetterRow[]> {\n const rows = await db\n .selectFrom(\"submission_deadletter\")\n .select([\"id\", \"site_slug\", \"payload\", \"turnstile\", \"error\", \"received_at\"])\n .where(\"replayed_at\", \"is\", null)\n .orderBy(\"received_at\", \"asc\")\n .execute();\n return rows.map((r) => ({\n id: r.id,\n siteSlug: r.site_slug,\n payload: JSON.parse(r.payload) as unknown,\n turnstile: JSON.parse(r.turnstile) as TurnstileVerification,\n error: r.error,\n receivedAt: r.received_at,\n }));\n}\n\n/** Mark a row's replay TERMINAL — it will never be picked up again. Only call\n * for outcomes that re-running cannot improve (accepted, rejected,\n * unknown-site). A replay whose lookup threw again is left untouched so the\n * next run retries it. */\nexport async function markDeadLetterReplayed(\n db: Db,\n id: string,\n outcome: string,\n submissionId: string | null,\n now: Date,\n): Promise<void> {\n await db\n .updateTable(\"submission_deadletter\")\n .set({\n replayed_at: now.toISOString(),\n replay_outcome: outcome,\n replay_submission_id: submissionId,\n })\n .where(\"id\", \"=\", id)\n .execute();\n}\n","import type { Db } from \"../db/client.js\";\nimport {\n listUnreplayedDeadLetters,\n markDeadLetterReplayed,\n type DeadLetterRow,\n} from \"../db/deadletter.js\";\nimport { ingestSubmission, type IngestDeps } from \"./ingest.js\";\n\nexport type ReplayResult = {\n /** Terminal outcomes this run: the row will never be picked up again. */\n replayed: Array<{ id: string; outcome: string; submissionId: string | null }>;\n /** Rows whose lookup threw AGAIN — left unreplayed for the next run. */\n stillFailing: Array<{ id: string; error: string }>;\n};\n\n/**\n * Run every unreplayed dead-letter back through `ingestSubmission` — the SAME\n * pipeline a live submission takes, so a replayed lead gets real spam\n * classification, notify, and fan-out, not a bespoke re-implementation.\n *\n * Each row replays with the Turnstile verification computed at receipt (tokens\n * expire in 300s; re-verifying is impossible by design, see deadletter.ts).\n *\n * `deps` must NOT wire `deadLetter`: a lookup that throws during replay would\n * otherwise dead-letter the SAME lead a second time while the original row stays\n * unreplayed — every retry would mint a duplicate. Enforced here by stripping the\n * key rather than trusting the caller. A thrown lookup leaves the row untouched\n * for the next run; every RESOLVED outcome (accepted, rejected, unknown-site) is\n * terminal — re-running cannot improve on an answer the store actually gave.\n *\n * Oldest-first (listUnreplayedDeadLetters orders by received_at) so the\n * duplicate/velocity spam signals see submissions in arrival order.\n */\nexport async function replayDeadLetters(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n): Promise<ReplayResult> {\n const rows = await listUnreplayedDeadLetters(db);\n const result: ReplayResult = { replayed: [], stillFailing: [] };\n\n for (const row of rows) {\n const outcome = await replayOne(db, deps, row);\n if (outcome.terminal) {\n result.replayed.push({\n id: row.id,\n outcome: outcome.outcome,\n submissionId: outcome.submissionId,\n });\n } else {\n result.stillFailing.push({ id: row.id, error: outcome.error });\n }\n }\n return result;\n}\n\nasync function replayOne(\n db: Db,\n deps: Omit<IngestDeps, \"deadLetter\">,\n row: DeadLetterRow,\n): Promise<\n | { terminal: true; outcome: string; submissionId: string | null }\n | { terminal: false; error: string }\n> {\n // Strip deadLetter defensively even though the type forbids it — a structurally\n // wider object passed through `as` must not re-arm the duplicate loop.\n const { deadLetter: _stripped, ...safeDeps } = deps as IngestDeps;\n void _stripped;\n let res;\n try {\n res = await ingestSubmission(safeDeps, row.siteSlug, row.payload, row.turnstile);\n } catch (err) {\n return { terminal: false, error: String(err) };\n }\n const now = deps.now();\n if (res.status === \"accepted\") {\n await markDeadLetterReplayed(db, row.id, \"accepted\", res.submissionId, now);\n return { terminal: true, outcome: \"accepted\", submissionId: res.submissionId };\n }\n const outcome = res.status === \"rejected\" ? `rejected:${res.reason}` : \"unknown-site\";\n await markDeadLetterReplayed(db, row.id, outcome, null, now);\n return { terminal: true, outcome, submissionId: null };\n}\n"],"mappings":";;;;;;;;AAsDA,eAAsB,0BAA0B,IAAkC;AAChF,QAAM,OAAO,MAAM,GAChB,WAAW,uBAAuB,EAClC,OAAO,CAAC,MAAM,aAAa,WAAW,aAAa,SAAS,aAAa,CAAC,EAC1E,MAAM,eAAe,MAAM,IAAI,EAC/B,QAAQ,eAAe,KAAK,EAC5B,QAAQ;AACX,SAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACtB,IAAI,EAAE;AAAA,IACN,UAAU,EAAE;AAAA,IACZ,SAAS,KAAK,MAAM,EAAE,OAAO;AAAA,IAC7B,WAAW,KAAK,MAAM,EAAE,SAAS;AAAA,IACjC,OAAO,EAAE;AAAA,IACT,YAAY,EAAE;AAAA,EAChB,EAAE;AACJ;AAMA,eAAsB,uBACpB,IACA,IACA,SACA,cACA,KACe;AACf,QAAM,GACH,YAAY,uBAAuB,EACnC,IAAI;AAAA,IACH,aAAa,IAAI,YAAY;AAAA,IAC7B,gBAAgB;AAAA,IAChB,sBAAsB;AAAA,EACxB,CAAC,EACA,MAAM,MAAM,KAAK,EAAE,EACnB,QAAQ;AACb;;;AC1DA,eAAsB,kBACpB,IACA,MACuB;AACvB,QAAM,OAAO,MAAM,0BAA0B,EAAE;AAC/C,QAAM,SAAuB,EAAE,UAAU,CAAC,GAAG,cAAc,CAAC,EAAE;AAE9D,aAAW,OAAO,MAAM;AACtB,UAAM,UAAU,MAAM,UAAU,IAAI,MAAM,GAAG;AAC7C,QAAI,QAAQ,UAAU;AACpB,aAAO,SAAS,KAAK;AAAA,QACnB,IAAI,IAAI;AAAA,QACR,SAAS,QAAQ;AAAA,QACjB,cAAc,QAAQ;AAAA,MACxB,CAAC;AAAA,IACH,OAAO;AACL,aAAO,aAAa,KAAK,EAAE,IAAI,IAAI,IAAI,OAAO,QAAQ,MAAM,CAAC;AAAA,IAC/D;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,UACb,IACA,MACA,KAIA;AAGA,QAAM,EAAE,YAAY,WAAW,GAAG,SAAS,IAAI;AAC/C,OAAK;AACL,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,iBAAiB,UAAU,IAAI,UAAU,IAAI,SAAS,IAAI,SAAS;AAAA,EACjF,SAAS,KAAK;AACZ,WAAO,EAAE,UAAU,OAAO,OAAO,OAAO,GAAG,EAAE;AAAA,EAC/C;AACA,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,IAAI,WAAW,YAAY;AAC7B,UAAM,uBAAuB,IAAI,IAAI,IAAI,YAAY,IAAI,cAAc,GAAG;AAC1E,WAAO,EAAE,UAAU,MAAM,SAAS,YAAY,cAAc,IAAI,aAAa;AAAA,EAC/E;AACA,QAAM,UAAU,IAAI,WAAW,aAAa,YAAY,IAAI,MAAM,KAAK;AACvE,QAAM,uBAAuB,IAAI,IAAI,IAAI,SAAS,MAAM,GAAG;AAC3D,SAAO,EAAE,UAAU,MAAM,SAAS,cAAc,KAAK;AACvD;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/alerts/analytics-health.ts","../src/cli/commands/report.ts"],"sourcesContent":["import { escapeHtml } from \"../util/html.js\";\n\n/**\n * The GA/Search enrichment outcome of one `report --due` draft run, aggregated\n * across the sites it drafted. `softFailedSites` is how many drafted sites had GA\n * OR Search enrichment ERROR (not \"not configured\" — that's a legitimate skip);\n * `configuredSites` is how many drafted sites had analytics configured at all\n * (the subject is set AND the site has a GA4 property or a search query). The ratio\n * is what distinguishes a fleet-wide subject outage from a single transient blip.\n */\nexport type AnalyticsRunHealth = {\n softFailedSites: number;\n configuredSites: number;\n};\n\nexport type AnalyticsAlert = { fire: boolean; reason: string };\n\n/** A lone failure never alerts — that's a per-site config issue, not the shared-\n * subject SPOF. Two-plus AND a majority of configured sites is the fleet signature. */\nconst MIN_FAILED_SITES = 2;\n\n/**\n * Decide whether a draft run's GA/Search soft-failures look FLEET-WIDE — i.e. the\n * one impersonated subject (`GA_SUBJECT`) lost access — rather than one site's\n * transient blip. PURE. Fires when at least {@link MIN_FAILED_SITES} configured\n * sites failed AND they are a majority (≥ half) of the analytics-configured sites\n * this run. With <2 configured sites it can't distinguish a SPOF from a one-off, so\n * it never fires (at fleet scale there are always many configured sites).\n */\nexport function assessAnalyticsAlert(h: AnalyticsRunHealth): AnalyticsAlert {\n const { softFailedSites, configuredSites } = h;\n const fire =\n configuredSites >= 2 &&\n softFailedSites >= MIN_FAILED_SITES &&\n softFailedSites * 2 >= configuredSites;\n const reason = fire\n ? `${softFailedSites} of ${configuredSites} analytics-configured sites had GA/Search enrichment fail this run — the shared GA_SUBJECT likely lost access (an offboarded user, revoked property access, or a botched role-account cutover). Reports were drafted with BLANK analytics.`\n : \"\";\n return { fire, reason };\n}\n\n/**\n * Compose the operator alert email for a fleet-wide analytics failure. PURE — the\n * caller (the `report --due` cron) sends it best-effort via Resend only when\n * {@link assessAnalyticsAlert} fires. `dashboardUrl` is the fleet homepage link.\n */\nexport function composeAnalyticsAlertEmail(\n h: AnalyticsRunHealth,\n dashboardUrl: string,\n): { subject: string; html: string } {\n const subject = `⚠ Fleet analytics enrichment failing — ${h.softFailedSites}/${h.configuredSites} sites`;\n const { reason } = assessAnalyticsAlert(h);\n const html = `<p><strong>${escapeHtml(reason)}</strong></p>\n<p>This usually means the Google Workspace user the service account impersonates can no longer read the GA4 / Search Console properties. Reports still send — but with blank analytics — until the subject is restored.</p>\n<p>Next step: follow the GA/Search subject runbook (<code>docs/runbooks/ga-search-role-account-cutover.md</code>) to restore or move the subject, then re-run <code>reddoor-maint report --due</code> and confirm the warning clears.</p>\n<p><a href=\"${escapeHtml(dashboardUrl)}\">Open the fleet dashboard →</a></p>`;\n return { subject, html };\n}\n","import { openBase, readAirtableConfig, type AirtableBase } from \"../../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateNextDueDates,\n type WebsiteRow,\n} from \"../../reports/airtable/websites.js\";\nimport { listAllReports, type ReportRow } from \"../../reports/airtable/reports.js\";\nimport { findDueReports, nextDueDate, reportPeriodKey } from \"../../reports/due.js\";\nimport { draftReportForSite } from \"../../reports/draft.js\";\nimport { reportTier } from \"../../reports/queue.js\";\nimport { readGaConfig } from \"../../reports/ga/config.js\";\nimport {\n assessAnalyticsAlert,\n composeAnalyticsAlertEmail,\n type AnalyticsRunHealth,\n} from \"../../alerts/analytics-health.js\";\nimport type { ReportType } from \"../../reports/types.js\";\nimport type { ScheduleMirror } from \"../../audits/health-mirror.js\";\nimport type { ReportMirror } from \"../../reports/report-mirror.js\";\nimport type { SiteMirror } from \"../../db/site-mirror.js\";\nimport { operatorEmail } from \"../../util/operator.js\";\n\nexport type ReportCommandOptions = {\n due?: boolean;\n preview?: boolean;\n enrich?: boolean;\n sendReady?: boolean;\n digest?: boolean;\n rerender?: string;\n type?: string;\n cwd?: string;\n};\n\n/**\n * Summary line for a drafted report, reflecting the single-queue outcome. `queued === false`\n * means a higher-or-equal-tier report was already pending for the site, so this draft was\n * created but deliberately left OUT of the approve queue. A non-empty `supersededIds` means it\n * un-queued that many lower-tier drafts. `null` is the previewOnly path (no Airtable queue).\n */\nfunction draftLine(\n reportId: string | undefined,\n queued: boolean | null,\n supersededIds: string[],\n verb = \"drafted\",\n): string {\n const id = reportId ?? \"(unknown)\";\n if (queued === false) {\n return `• ${verb} but NOT queued: ${id} — a higher-or-equal-tier report is already pending approval`;\n }\n const sup =\n supersededIds.length > 0\n ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? \"s\" : \"\"})`\n : \"\";\n return `✓ ${verb}: ${id}${sup}`;\n}\n\n/**\n * Parse the single-site `--type` flag. Only Maintenance and Testing are draftable\n * this way — Launch has the `launch <site>` command and Announcement has\n * `announce <site>`, each with its own purpose-built flow. Case-insensitive;\n * defaults to Maintenance (the historical single-site behaviour). Throws an\n * exitCode-2 usage error on anything else. PURE.\n */\nexport function parseSingleSiteReportType(raw: string | undefined): ReportType {\n if (raw === undefined || raw.trim() === \"\") return \"Maintenance\";\n const norm = raw.trim().toLowerCase();\n if (norm === \"maintenance\") return \"Maintenance\";\n if (norm === \"testing\") return \"Testing\";\n const hint =\n norm === \"launch\"\n ? \" — use the `launch <site>` command\"\n : norm === \"announcement\"\n ? \" — use the `announce <site>` command\"\n : \"\";\n throw Object.assign(\n new Error(`--type must be Maintenance or Testing (got ${JSON.stringify(raw)})${hint}`),\n { exitCode: 2 },\n );\n}\n\n/** Dashboard origin for digest /s/<slug> links. DASHBOARD_BASE_URL overrides the\n * production default; the trailing slash (if any) is trimmed by runDigest. */\nfunction dashboardBaseUrl(): string {\n return process.env.DASHBOARD_BASE_URL?.trim() || \"https://reddoor-maintenance.netlify.app\";\n}\n\nexport async function runReportCommand(\n slug: string | undefined,\n opts: ReportCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (opts.digest) {\n const { runDigest } = await import(\"../../reports/digest.js\");\n return runDigest({ baseUrl: dashboardBaseUrl() });\n }\n\n if (opts.sendReady) {\n const { sendApprovedReports } = await import(\"../../reports/send/orchestrate.js\");\n // #539 Phase 5: a Launch send flips Status + stamps `Launched at` on the\n // Websites row; #643 retired the hourly sync, so these mirrors are the ONLY\n // way either write reaches Turso.\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const { mirrorWrite } = await import(\"../../db/freeze.js\");\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { mirrorReportPatch } = await import(\"../../db/fleet-state.js\");\n return sendApprovedReports({\n siteMirror: await makeSiteMirror(),\n // stampSent's Turso shadow, routed through mirrorWrite so the freeze\n // switch owns the semantics: strict rethrows and the send loop reds the\n // run. Mirrors stampSent exactly — the 409 replay path leaves Airtable's\n // `Resend message ID` untouched, so the shadow omits it there too.\n reportSentMirror: (reportId, sentAt, messageId) =>\n mirrorWrite(`stamp-sent ${reportId}`, async () => {\n const db = await openDb(readDbConfig());\n await mirrorReportPatch(db, reportId, {\n sent_at: sentAt.toISOString(),\n ...(messageId !== null ? { resend_message_id: messageId } : {}),\n });\n }),\n });\n }\n\n // Refresh ONE unsent report's stored body so the console preview reflects\n // commentary edited after drafting. Runs here rather than in a Netlify\n // function because rendering needs sharp — see rerender.ts.\n if (opts.rerender) {\n const { rerenderReport, formatRerenderResult } = await import(\"../../reports/send/rerender.js\");\n const { renderReportFromRow } = await import(\"../../reports/send/render-from-row.js\");\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { getReportById, getSiteById, storeRenderedHtml } =\n await import(\"../../db/fleet-state.js\");\n const { loadHeaderImage } = await import(\"../../db/header-images.js\");\n const { fetchAttachmentBytes } = await import(\"../../reports/airtable/attachments.js\");\n const db = await openDb(readDbConfig());\n const result = await rerenderReport(\n {\n getReport: (id) => getReportById(db, id),\n getSite: (id) => getSiteById(db, id),\n loadHeaderPlate: async (id) => (await loadHeaderImage(db, id))?.bytes ?? null,\n fetchAirtableHeader: async (url) => (await fetchAttachmentBytes(url)).bytes,\n render: (site, report, plate) => renderReportFromRow(site, report, plate),\n store: (id, html) => storeRenderedHtml(db, id, html),\n },\n opts.rerender,\n );\n // Exit 1 on anything but a render, so a dispatched run that refused is RED\n // rather than a green run that silently changed nothing.\n return { output: formatRerenderResult(result), code: result.status === \"rendered\" ? 0 : 1 };\n }\n\n if (opts.due) {\n return runDueDraft();\n }\n\n if (slug) {\n // Validate the type BEFORE any Airtable access so a bad --type fails fast (and\n // without needing credentials).\n const reportType = parseSingleSiteReportType(opts.type);\n return runSingleSiteDraft(slug, {\n previewOnly: Boolean(opts.preview),\n enrich: Boolean(opts.enrich),\n reportType,\n });\n }\n\n throw Object.assign(\n new Error(\n \"Usage: reddoor-maint report [<slug>] [--type <Maintenance|Testing>] [--due] [--preview] [--enrich] [--send-ready] [--digest]\",\n ),\n {\n exitCode: 2,\n },\n );\n}\n\nasync function runDueDraft(): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n // Phase 3 dual-write (#539): mirror real next-due writes into site_schedule.\n // Null when libSQL creds are absent — the Airtable path is unchanged.\n const { makeScheduleMirrorBestEffort } = await import(\"../../audits/health-mirror.js\");\n // Phase 5 dual-write (#539): mirror this batch's report writes — the created\n // rows, their bodies, and the queue flags. Unlike the schedule mirror this is\n // never null — creds-absent is reported on the REPORT_MIRROR line rather than\n // being indistinguishable from success, the failure mode that hid #585.\n const { makeReportMirror } = await import(\"../../reports/report-mirror.js\");\n const { makeSiteMirror } = await import(\"../../db/site-mirror.js\");\n const result = await draftDueReports(\n base,\n new Date(),\n await makeScheduleMirrorBestEffort(),\n await makeReportMirror(),\n await makeSiteMirror(),\n );\n await alertOnFleetAnalyticsFailure(result.health);\n return { output: result.output, code: result.code };\n}\n\n/** Best-effort: when a draft run's GA/Search soft-failures look FLEET-WIDE (the shared\n * GA_SUBJECT lost access — see assessAnalyticsAlert), email the operator one alert.\n * NEVER throws — a Resend or config hiccup must not fail the nightly draft cron; the\n * per-site `⚠ GA skipped` warnings + the run-output line still carry the signal. The\n * daily idempotency key dedupes multiple runs in one day (Resend dedupes 24h). */\nasync function alertOnFleetAnalyticsFailure(health: AnalyticsRunHealth): Promise<void> {\n if (!assessAnalyticsAlert(health).fire) return;\n try {\n const to = operatorEmail();\n const { subject, html } = composeAnalyticsAlertEmail(health, dashboardBaseUrl());\n const { defaultResendClient } = await import(\"../../reports/send/resend.js\");\n await defaultResendClient().send({\n from: \"Reddoor Reports <reports@reddoorla.com>\",\n to: [to],\n subject,\n html,\n idempotencyKey: `analytics-alert-${new Date().toISOString().slice(0, 10)}`,\n });\n console.warn(`⚠ ${subject} — operator alerted (${to})`);\n } catch (e) {\n console.warn(`⚠ analytics-failure alert send failed: ${(e as Error).message}`);\n }\n}\n\n/**\n * Write each site's code-computed next-maintenance / next-testing date back to Airtable\n * (date-only, or null when there's no schedule), so the \"next\" dates shown there derive\n * from the SAME `nextDueDate` the scheduler uses — replacing the old Airtable formula +\n * automation. Best-effort and per-site isolated: a missing `Next … at` column or one bad\n * row warns and is skipped, never aborting the nightly draft run.\n *\n * The diff-guard (#539 Phase 3): a site whose computed dates equal what its row\n * already holds is SKIPPED — before it, every one of the 44 sites got a nightly\n * write while only the handful whose schedule actually moved needed one (a\n * never-maintained site re-wrote null over null forever). This also scopes the\n * write to maintained sites by construction: no schedule → computed null →\n * equal to the stored null → skipped. Each real write dual-writes through\n * `scheduleMirror` into site_schedule (null mirror = Turso creds absent; the\n * hourly sync converges either way). The NEXT_DUE_WRITE line is observability,\n * not a CI gate — nothing greps it yet.\n */\nexport async function writeNextDueDates(\n base: AirtableBase,\n websites: WebsiteRow[],\n reports: ReportRow[],\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n): Promise<void> {\n const ymd = (d: Date | null): string | null => (d ? d.toISOString().slice(0, 10) : null);\n let wrote = 0;\n let skipped = 0;\n let failed = 0;\n let mirrored = 0;\n let mirrorFailed = 0;\n let mirrorMissed = 0;\n for (const site of websites) {\n // The whole per-site body sits in ONE try — compute included — so a bad\n // row can only cost its own write, exactly the pre-diff-guard blast radius.\n try {\n const maintenanceAt = ymd(nextDueDate(site, reports, \"Maintenance\", today));\n const testingAt = ymd(nextDueDate(site, reports, \"Testing\", today));\n if (maintenanceAt === site.nextMaintenanceAt && testingAt === site.nextTestingAt) {\n skipped++;\n continue;\n }\n const fields = await updateNextDueDates(base, site.id, { maintenanceAt, testingAt });\n wrote++;\n if (scheduleMirror) {\n try {\n // false = the UPDATE matched no site_schedule row (site created in\n // Airtable since the last hourly import) — missed, not mirrored.\n if (await scheduleMirror(site.id, fields, today.toISOString())) mirrored++;\n else mirrorMissed++;\n } catch (e) {\n mirrorFailed++;\n console.warn(`⚠ [schedule-mirror] ${site.name}: ${(e as Error).message}`);\n }\n }\n } catch (e) {\n failed++;\n console.warn(`⚠ next-due write skipped for ${site.name}: ${(e as Error).message}`);\n }\n }\n // failed= keeps a write outage visible: without it wrote+skipped silently\n // undercounts, and a FULL outage prints wrote=0 skipped=0 — indistinguishable\n // from an empty fleet.\n // A null mirror writes nothing and throws nothing, so its only trace used to\n // be an ABSENT suffix — which reads exactly like a healthy run. `mirror=absent`\n // states it instead. Counters stay off in that case deliberately: `mirrored=0`\n // would claim a write that returned nothing, not one that was never attempted.\n const mirrorNote = scheduleMirror\n ? ` mirrored=${mirrored} mirror_failed=${mirrorFailed} mirror_missed=${mirrorMissed}`\n : \" mirror=absent\";\n console.log(`NEXT_DUE_WRITE wrote=${wrote} skipped=${skipped} failed=${failed}${mirrorNote}`);\n}\n\nexport async function draftDueReports(\n base: AirtableBase,\n today: Date,\n scheduleMirror: ScheduleMirror | null = null,\n /** #539 Phase 5: Turso write-through for rows this batch CREATES. Forwarded\n * to every draftReportForSite call — the nightly batch is the only unattended\n * creator of report rows, so a dropped pass-through leaves each new draft\n * invisible to the Turso-backed console. */\n reportMirror?: ReportMirror,\n /** #539 Phase 5: the Websites-row twin — drafting stamps `Analytics soft-fail\n * at` on the SITE row, a different Turso table from the report. */\n siteMirror?: SiteMirror,\n): Promise<{ output: string; code: number; health: AnalyticsRunHealth }> {\n const mirrorOpt = {\n ...(reportMirror ? { reportMirror } : {}),\n ...(siteMirror ? { siteMirror } : {}),\n };\n const websites = await listWebsites(base);\n // ONE unfiltered fetch for the whole fleet. Per-site queries can't be pushed to\n // Airtable anyway (linked-record fields aren't formula-filterable by record id),\n // and findDueReports + the period guard below match on siteId in memory.\n const reports = await listAllReports(base);\n\n // Refresh every site's code-owned next-due dates first, so they stay current even on\n // a run where nothing is due (the early return below).\n await writeNextDueDates(base, websites, reports, today, scheduleMirror);\n\n const due = findDueReports(websites, reports, today);\n\n // GA/Search enrichment is configured globally (the impersonation subject) AND\n // per-site (a GA4 property or a search query). `gaConfigured` is the global half;\n // the per-site half is checked as each draft runs, to build the fleet-wide\n // analytics-failure alert's denominator (see alertOnFleetAnalyticsFailure).\n const gaConfigured = readGaConfig() !== null;\n // Truthy (not `!== null`) to mirror fetchGaUsers/fetchSearch's own gate exactly\n // (`!siteRow.ga4PropertyId`), so an empty-string cell counts as not-configured in\n // BOTH places and can't inflate the alert denominator.\n const isAnalyticsConfigured = (s: WebsiteRow): boolean =>\n gaConfigured && Boolean(s.ga4PropertyId || s.searchQuery);\n\n if (due.length === 0) {\n return {\n output: \"No reports due.\",\n code: 0,\n health: { softFailedSites: 0, configuredSites: 0 },\n };\n }\n\n const lines: string[] = [];\n let softFailedSites = 0;\n let searchDefaultMisses = 0;\n let searchPropertiesMissing = 0;\n let gaConfiguredSites = 0;\n let skipped = 0;\n for (const item of due) {\n // Idempotency: a re-run must not re-draft a (site, type) already drafted this\n // recurrence. The dueDate's YYYY-MM is the stable per-cycle key. Match against the\n // reports we already fetched — no extra query on the hot path.\n const period = reportPeriodKey(item.dueDate);\n const existing = reports.find(\n (r) => r.siteId === item.site.id && r.reportType === item.reportType && r.period === period,\n );\n\n // A row already exists for THIS period. Two cases:\n // - Draft ready → truly done, skip (the idempotent re-run path).\n // - NOT ready → a crash between createDraft and setDraftReady wedged it: the\n // row exists (so we never re-draft) yet it's never sendable (listSendable\n // needs Draft ready). COMPLETE it in place instead of skipping forever —\n // re-render → re-upload the HTML → flip Draft ready on the EXISTING row.\n if (existing) {\n if (existing.draftReady) {\n skipped++;\n lines.push(`• skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);\n continue;\n }\n // A not-ready row is normally a crash between createDraft and setDraftReady — re-complete\n // it in place. BUT queueDraft also clears Draft ready on rows it supersedes/blocks, and\n // those must NOT be re-completed: doing so would re-render and APPEND a duplicate HTML\n // attachment every nightly run, only to be re-blocked. Distinguish the two: if a\n // higher-or-equal-tier report is still pending for this site, this row was intentionally\n // un-queued (not crashed) — skip it until the blocker is sent/approved or the month rolls.\n const blockedByPending = reports.some(\n (r) =>\n r.siteId === item.site.id &&\n r.id !== existing.id &&\n r.sentAt === null &&\n r.draftReady &&\n reportTier(r.reportType) >= reportTier(item.reportType),\n );\n if (blockedByPending) {\n skipped++;\n lines.push(\n `• skipped (superseded — a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`,\n );\n continue;\n }\n try {\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n completeRowId: existing.id,\n existingRow: existing,\n ...mirrorOpt,\n });\n existing.draftReady = result.queued === true;\n lines.push(\n draftLine(\n result.reportRow?.reportId ?? existing.reportId,\n result.queued,\n result.supersededIds,\n \"completed half-made draft\",\n ),\n );\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n continue;\n }\n\n // Pile-up guard: don't accrue a fresh new-period draft every recurrence for a\n // site nobody ever approves. The period key follows the DUE month, so each\n // recurrence wants a new (later-period) draft — but if a PRIOR draft is still\n // pending approval, a new one just stacks. Skip creating the new one while an\n // earlier-period draft for this (site, type) sits ready-but-unsent.\n //\n // `r.draftReady` is load-bearing: a draft a higher tier SUPERSEDED has\n // draftReady=false and never gets a Sent at, so without this clause it would\n // match (sentAt null + earlier period) and block EVERY future draft for the\n // site forever. Pending-approval means draftReady=true AND sentAt=null.\n const pendingEarlier = reports.find(\n (r) =>\n r.siteId === item.site.id &&\n r.reportType === item.reportType &&\n r.draftReady &&\n r.sentAt === null &&\n r.period !== null &&\n r.period < period,\n );\n if (pendingEarlier) {\n skipped++;\n lines.push(\n `• skipped: ${item.site.name} ${item.reportType} already has an unsent ${pendingEarlier.period} draft pending approval`,\n );\n continue;\n }\n\n try {\n // Pass the SAME key the guard searches by, so the stamped Period always\n // matches a future run's reportPeriodKey(dueDate) — even if this run lags\n // into a later month than the dueDate.\n const result = await draftReportForSite(base, item.site, item.reportType, {\n period,\n ...mirrorOpt,\n });\n lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));\n // Keep the in-memory snapshot current so the guard's `.some()` check on the\n // NEXT iteration of this same run catches a row we JUST created — rather than\n // relying on findDueReports never emitting two items for the same (site, type).\n if (result.reportRow) reports.push(result.reportRow);\n // Count sites (not individual GA/Search failures) so a fleet-wide enrichment\n // outage is one obvious line at the bottom, not 200 buried console.warns.\n if (isAnalyticsConfigured(item.site)) gaConfiguredSites++;\n if (result.softFailures.length > 0) softFailedSites++;\n if (result.searchDefaultMissed) searchDefaultMisses++;\n if (result.searchPropertyMissing) searchPropertiesMissing++;\n } catch (e) {\n lines.push(`✗ failed: ${item.site.name} ${item.reportType} — ${(e as Error).message}`);\n }\n }\n if (skipped > 0) {\n lines.push(`• ${skipped} already drafted or pending this period`);\n }\n if (softFailedSites > 0) {\n lines.push(\n `⚠ ${softFailedSites} site${softFailedSites === 1 ? \"\" : \"s\"} had GA/Search enrichment fail — drafted with blank analytics; check the logs above`,\n );\n }\n if (searchDefaultMisses > 0) {\n lines.push(\n `⚑ ${searchDefaultMisses} site${searchDefaultMisses === 1 ? \"\" : \"s\"} returned no Search Console data for their name — set an explicit \"Search query\" in Airtable to track brand presence.`,\n );\n }\n if (searchPropertiesMissing > 0) {\n lines.push(\n `⚑ ${searchPropertiesMissing} site${searchPropertiesMissing === 1 ? \"\" : \"s\"} matched NO Search Console property — verify the domain property exists and the service account has access (a \"Search query\" change cannot fix this).`,\n );\n }\n return {\n output: lines.join(\"\\n\"),\n code: lines.some((l) => l.startsWith(\"✗\")) ? 1 : 0,\n health: { softFailedSites, configuredSites: gaConfiguredSites },\n };\n}\n\nasync function runSingleSiteDraft(\n slug: string,\n opts: { previewOnly: boolean; enrich: boolean; reportType: ReportType },\n): Promise<{ output: string; code: number }> {\n const base = openBase(readAirtableConfig());\n const websites = await listWebsites(base);\n const site = websites.find((w) => siteSlug(w.name) === slug);\n if (!site) {\n throw Object.assign(new Error(`No Websites row matched slug \"${slug}\"`), { exitCode: 2 });\n }\n // Phase 5 dual-write (#539) — but only on the path that actually creates a\n // row. A preview writes nothing to Airtable, so opening a libSQL handle for\n // it would be pure cost (and `report --preview` is the one draft path that\n // deliberately does no store IO at all).\n const reportMirror = opts.previewOnly\n ? null\n : await (await import(\"../../reports/report-mirror.js\")).makeReportMirror();\n const siteMirror = opts.previewOnly\n ? null\n : await (await import(\"../../db/site-mirror.js\")).makeSiteMirror();\n const result = await draftReportForSite(opts.previewOnly ? null : base, site, opts.reportType, {\n previewOnly: opts.previewOnly,\n // Only forced on. Left undefined, draftReportForSite keeps its default\n // (enrich iff there is a base), so the real drafting path is untouched.\n ...(opts.enrich ? { enrich: true } : {}),\n ...(reportMirror ? { reportMirror } : {}),\n ...(siteMirror ? { siteMirror } : {}),\n });\n if (opts.previewOnly) {\n return { output: `Preview written to ${result.htmlPath}`, code: 0 };\n }\n return {\n output: draftLine(\n result.reportRow?.reportId,\n result.queued,\n result.supersededIds,\n \"Draft created\",\n ),\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/report-mirror.ts"],"sourcesContent":["/** #539 Phase 5: the Turso write-through for the Reports write surface.\n *\n * Before this, the only report mirrors were UPDATEs on the request path\n * (approve, override, delivery status, commentary). Everything the DRAFTING\n * path writes — the row itself, the rendered body, the queue flag, a re-run's\n * refreshed scores — reached Turso only via the hourly sync. Two of those are\n * visible to the operator today: a fresh draft's row does not exist, and its\n * preview route answers \"No rendered body stored\" for up to an hour. At the\n * freeze they stop being windows.\n *\n * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.\n * #585 is the reason: Phase 3's next-due mirror silently no-opped in production\n * for weeks because the factory returned null without creds, and a dead\n * dual-write then looked exactly like a healthy one — the only tell was an\n * ABSENT log suffix nobody was watching for. Here creds-absent is a state the\n * mirror REPORTS, so every write emits one REPORT_MIRROR line and an absent\n * line means the wiring is gone, not that conditions were quiet.\n */\nimport { openDb, readDbConfig, type Db } from \"../db/client.js\";\nimport {\n mirrorReportInsert,\n mirrorReportPatch,\n storeRenderedHtml,\n type ReportMirrorPatch,\n} from \"../db/fleet-state.js\";\nimport type { CreatedDraftMirror } from \"./airtable/reports.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"../db/freeze.js\";\n\n/** The three shapes a report write takes. Injected as ONE object rather than\n * three parameters because they share a db handle and always travel together:\n * a caller holding `created` but not `body` produces exactly the half-mirrored\n * state (row present, preview 404) this module exists to prevent. */\nexport type ReportMirror = {\n /** A row Airtable just CREATED, as Airtable echoed it back. */\n created: CreatedDraftMirror;\n /** A freshly rendered body for an existing row. */\n body: (reportId: string, html: string) => Promise<void>;\n /** Columns just written to an existing row. */\n patch: (reportId: string, patch: ReportMirrorPatch) => Promise<void>;\n};\n\n/** Build the drafting-path mirror. Never throws and never returns null:\n * Airtable is still authoritative through Phase 5, so a mirror problem must\n * not cost a draft the operator is waiting on — the hourly sync converges\n * whatever this misses. `open` is injectable for tests. */\nexport async function makeReportMirror(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n /** #612. `true` = Turso is the store that must succeed, so every failure\n * throws instead of being logged and swallowed. Defaulted from the shipped\n * constant and injected by tests, so both sides stay proven. */\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<ReportMirror> {\n let db: Db | null = null;\n let why = \"\";\n try {\n db = await open();\n } catch (e) {\n why = (e as Error).message;\n }\n // Frozen: refuse to hand back a mirror that cannot write — see makeSiteMirror\n // for why this fails at construction rather than per write.\n if (strict && !db) throw new Error(`REPORT_MIRROR unavailable: ${why}`);\n\n const run = async (reportId: string, op: string, work: (db: Db) => Promise<void>) => {\n if (!db) {\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=absent reason=${why}`);\n return;\n }\n try {\n await work(db);\n } catch (e) {\n console.log(\n `REPORT_MIRROR report=${reportId} op=${op} mirrored=0 error=${(e as Error).message}`,\n );\n if (strict) throw e;\n return;\n }\n console.log(`REPORT_MIRROR report=${reportId} op=${op} mirrored=1`);\n };\n\n return {\n created: (rec) => run(rec.id, \"created\", (d) => mirrorReportInsert(d, rec)),\n body: (reportId, html) => run(reportId, \"body\", (d) => storeRenderedHtml(d, reportId, html)),\n patch: (reportId, patch) =>\n run(reportId, \"patch\", (d) => mirrorReportPatch(d, reportId, patch)),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA6CA,eAAsB,iBACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GAIrD,SAAkB,wBACK;AACvB,MAAI,KAAgB;AACpB,MAAI,MAAM;AACV,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,UAAO,EAAY;AAAA,EACrB;AAGA,MAAI,UAAU,CAAC,GAAI,OAAM,IAAI,MAAM,8BAA8B,GAAG,EAAE;AAEtE,QAAM,MAAM,OAAO,UAAkB,IAAY,SAAoC;AACnF,QAAI,CAAC,IAAI;AACP,cAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,2BAA2B,GAAG,EAAE;AACrF;AAAA,IACF;AACA,QAAI;AACF,YAAM,KAAK,EAAE;AAAA,IACf,SAAS,GAAG;AACV,cAAQ;AAAA,QACN,wBAAwB,QAAQ,OAAO,EAAE,qBAAsB,EAAY,OAAO;AAAA,MACpF;AACA,UAAI,OAAQ,OAAM;AAClB;AAAA,IACF;AACA,YAAQ,IAAI,wBAAwB,QAAQ,OAAO,EAAE,aAAa;AAAA,EACpE;AAEA,SAAO;AAAA,IACL,SAAS,CAAC,QAAQ,IAAI,IAAI,IAAI,WAAW,CAAC,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAAA,IAC1E,MAAM,CAAC,UAAU,SAAS,IAAI,UAAU,QAAQ,CAAC,MAAM,kBAAkB,GAAG,UAAU,IAAI,CAAC;AAAA,IAC3F,OAAO,CAAC,UAAU,UAChB,IAAI,UAAU,SAAS,CAAC,MAAM,kBAAkB,GAAG,UAAU,KAAK,CAAC;AAAA,EACvE;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/schema.ts"],"sourcesContent":["// The hand-written shape Kysely types every query from. SQLite stores TEXT/INTEGER\n// only, so these are the column *shapes* — the value domains (form_type, status,\n// notify_status) are still narrowed at read time by the validators in\n// src/reports/submission-row.ts. Keep this in lockstep with src/db/migrations.ts;\n// drift is caught by the in-memory tests in tests/db/, not by codegen.\n\nexport interface SubmissionsTable {\n id: string;\n submission_id: number | null;\n site_id: string;\n form_type: string;\n name: string;\n email: string;\n phone: string | null;\n message: string | null;\n extra_fields: string | null;\n source_url: string | null;\n utm: string | null;\n submitted_at: string | null;\n status: string;\n notify_status: string;\n resend_message_id: string | null;\n spam_score: number | null;\n spam_reason: string | null;\n fanout_status: string | null;\n}\n\nexport interface SpamScreenoutsTable {\n site_id: string;\n date: string;\n honeypot: number;\n too_fast: number;\n marked_spam: number;\n}\n\nexport interface FleetEventsTable {\n id: string;\n ts: string;\n type: string;\n site_id: string | null;\n site_name: string | null;\n summary: string;\n data: string | null;\n created_at: string;\n}\n\nexport interface MigrationsTable {\n id: string;\n applied_at: string;\n}\n\n/** A lead whose site lookup threw mid-ingest (migration 0006). `payload` and\n * `turnstile` are JSON — the raw wire body and the verification computed at\n * receipt — exactly what `db replay-deadletters` feeds back through\n * `ingestSubmission` once the lookup recovers. */\nexport interface SubmissionDeadletterTable {\n id: string;\n site_slug: string;\n payload: string;\n turnstile: string;\n error: string;\n received_at: string;\n replayed_at: string | null;\n replay_outcome: string | null;\n replay_submission_id: string | null;\n}\n\n/** Operator-owned fleet config (migration 0007). PK = Airtable rec id (D1).\n * `legacy` holds the 33 populated-but-code-unreferenced Airtable columns as a\n * JSON object keyed by original column name; the plaintext DNS/cms credential\n * cells never migrate (operator ruling 2026-08-23). */\nexport interface SitesTable {\n id: string;\n slug: string;\n name: string;\n url: string | null;\n status: string | null;\n point_of_contact: string | null;\n maintenance_freq: string | null;\n testing_freq: string | null;\n maintenance_day: string | null;\n testing_day: string | null;\n ga4_property_id: string | null;\n search_query: string | null;\n search_console_property: string | null;\n git_repo: string | null;\n netlify_id: string | null;\n report_recipients_to: string | null;\n report_recipients_cc: string | null;\n copy_intro: string | null;\n copy_contact: string | null;\n copy_footer: string | null;\n newsletter_webhook: string | null;\n mailchimp_api_key: string | null;\n mailchimp_audience_id: string | null;\n notify_routing: string | null;\n require_turnstile: number;\n accepted_watch_conditions: string | null;\n prismic_ack_until: string | null;\n launched_at: string | null;\n header_image: Uint8Array | null;\n header_image_filename: string | null;\n header_image_type: string | null;\n header_image_generated_at: string | null;\n legacy: string | null;\n}\n\n/** Nightly-cron-owned health/telemetry (migration 0007) — one row per site,\n * written as one batched upsert per sweep family. `analytics_soft_fail_at` is\n * the \"Analytics soft-fail at\" column code always wrote best-effort but no\n * operator ever created in Airtable. */\nexport interface SiteHealthTable {\n site_id: string;\n p_score: number | null;\n r_score: number | null;\n bp_score: number | null;\n seo_score: number | null;\n lighthouse_at: string | null;\n a11y_violations: number | null;\n deps_drifted: number | null;\n deps_major_behind: number | null;\n deps_outdated: number | null;\n deps_major_outdated: number | null;\n vulns_critical: number | null;\n vulns_high: number | null;\n vulns_moderate: number | null;\n vulns_low: number | null;\n security_audit_at: string | null;\n security_advisories: string | null;\n auto_fix_attempts: number | null;\n analytics_soft_fail_at: string | null;\n cert_days_remaining: number | null;\n domain_checked_at: string | null;\n deploy_status: string | null;\n last_deploy_at: string | null;\n deploy_log_url: string | null;\n deploy_checked_at: string | null;\n function_health: string | null;\n cms_reachable: string | null;\n turnstile_widget: string | null;\n function_health_checked_at: string | null;\n crossbrowser_ok: number | null;\n mobile_ok: number | null;\n links_ok: number | null;\n broken_links: number | null;\n browser_checked_at: string | null;\n uptime_reachable: string | null;\n titles_meta_ok: string | null;\n smoke_ok: string | null;\n last_smoke_at: string | null;\n form_e2e_ok: string | null;\n form_e2e_checked_at: string | null;\n renovate_failing_cis: number | null;\n default_branch_ci: string | null;\n last_commit_at: string | null;\n github_signals_at: string | null;\n prismic_models: string | null;\n prismic_models_checked_at: string | null;\n prismic_models_drift: string | null;\n}\n\n/** Code-derived schedule (migration 0007) — written by updateNextDueDates. */\nexport interface SiteScheduleTable {\n site_id: string;\n next_maintenance_at: string | null;\n next_testing_at: string | null;\n computed_at: string | null;\n}\n\n/** Report rows (migration 0007). PK = Airtable rec id. `checklist` is JSON\n * keyed by the STABLE checklist key (src/reports/checklist.ts), not the\n * Airtable column name; `rendered_html` is the downloaded attachment body. */\nexport interface ReportsTable {\n id: string;\n site_id: string | null;\n report_id: string | null;\n report_type: string | null;\n period: string | null;\n period_start: string | null;\n period_end: string | null;\n completed_on: string | null;\n lighthouse_performance: number | null;\n lighthouse_accessibility: number | null;\n lighthouse_best_practices: number | null;\n lighthouse_seo: number | null;\n ga_users_current: number | null;\n ga_users_previous: number | null;\n search_found_page1: number | null;\n search_position: number | null;\n last_tested_date: string | null;\n commentary: string | null;\n subject_override: string | null;\n draft_ready: number;\n approved_to_send: number;\n approved_at: string | null;\n approved_by: string | null;\n send_override: number;\n override_reason: string | null;\n override_by: string | null;\n override_at: string | null;\n sent_at: string | null;\n delivery_status: string | null;\n resend_message_id: string | null;\n checklist: string | null;\n checklist_auto_evidence: string | null;\n rendered_html: string | null;\n}\n\n/** One prospect-audit run (migration 0009). `token` is the 128-bit unguessable\n * public handle for GET /r/{token}; `result_json` holds the full\n * ProspectAuditResult (src/prospect/types.ts). */\nexport interface ProspectAuditsTable {\n id: string;\n token: string;\n url: string;\n /** One key per site, however its address was written (migration 0013). `url`\n * records what we audited; this is what two audits of one site are grouped\n * by. Null on rows written before the column existed. See siteKey(). */\n site_key: string | null;\n business: string | null;\n created_at: string;\n status: string;\n result_json: string;\n /** Operator edits, `{ \"<key>\": { original, text } }` as JSON. Null on every\n * row written before migration 0015, and on any report never edited. */\n overrides_json: string | null;\n /** ISO-8601 of the last override write. Null when never edited. */\n edited_at: string | null;\n /** ISO-8601 of the last fetch that did NOT carry an edit session. Null when\n * nobody outside the edit flow has opened it. */\n opened_at: string | null;\n}\n\n/** The digest's prior-run snapshot (migration 0011). One row, `id` = the\n * constant singleton key; `snapshot` is the whole DigestSnapshot as JSON. */\nexport interface DigestStateTable {\n id: string;\n snapshot: string;\n updated_at: string | null;\n}\n\n/** Every table the app owns, as a RUNTIME value (#612 review).\n *\n * The `Database` interface below is types-only, so nothing could assert that a\n * backup actually covered it — `tables=N` was printed by the verifier and never\n * checked, meaning a table a migration failed to create would ride green\n * forever. The compile-time check under this list makes forgetting an entry a\n * build error naming the missing table. */\nexport const DATABASE_TABLES = [\n \"submissions\",\n \"spam_screenouts\",\n \"fleet_events\",\n \"_migrations\",\n \"submission_deadletter\",\n \"sites\",\n \"site_health\",\n \"site_schedule\",\n \"reports\",\n \"prospect_audits\",\n \"digest_state\",\n] as const;\n\nexport type DatabaseTable = (typeof DATABASE_TABLES)[number];\n\nexport interface Database {\n submissions: SubmissionsTable;\n spam_screenouts: SpamScreenoutsTable;\n fleet_events: FleetEventsTable;\n _migrations: MigrationsTable;\n submission_deadletter: SubmissionDeadletterTable;\n sites: SitesTable;\n site_health: SiteHealthTable;\n site_schedule: SiteScheduleTable;\n reports: ReportsTable;\n prospect_audits: ProspectAuditsTable;\n digest_state: DigestStateTable;\n}\n\n/** Compile-time exhaustiveness: if a table is added to `Database` and not to\n * `DATABASE_TABLES`, this errors and NAMES it. */\ntype MissingFromDatabaseTables = Exclude<keyof Database, DatabaseTable>;\nconst _everyTableIsListed: MissingFromDatabaseTables extends never\n ? true\n : MissingFromDatabaseTables = true;\nvoid _everyTableIsListed;\n"],"mappings":";AAwPO,IAAM,kBAAkB;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/site-mirror.ts"],"sourcesContent":["/** #539 Phase 5: the Turso write-through for the ONE-OFF Websites writers.\n *\n * Phase 3 mirrored the nightly sweep — the audit write-back, github-signals and\n * the next-due dates. It did not touch the writers that run on their own\n * schedule or on demand: the analytics soft-fail stamp, the Renovate\n * auto-fix counter, the prismic-models verdict, `updateLaunched`, the\n * forms notify target, and the SINGLE-SITE audit write-back (only the fleet\n * path ever passed a mirror). Each of those reached Turso solely via the hourly\n * sync, which stops existing at the freeze.\n *\n * It lives in `src/db` rather than a feature folder because its callers span\n * audits, recipes, CLI commands and the send path — no one feature owns it.\n *\n * Deliberately UNLIKE `makeHealthMirrorBestEffort`, this never returns null.\n * #585 is the reason: that factory returned null without creds and the\n * dual-write silently no-opped in production for weeks, because a dead mirror\n * and a healthy one produced identical output — the only tell was an ABSENT log\n * suffix nobody was watching for. Here creds-absent is a state the mirror\n * REPORTS, so a missing SITE_MIRROR line means the wiring is gone.\n */\nimport { openDb, readDbConfig, type Db } from \"./client.js\";\nimport { mirrorHealthFields, mirrorSiteFields, mirrorSiteInsert } from \"./fleet-state.js\";\nimport { TURSO_IS_AUTHORITATIVE } from \"./freeze.js\";\n\n/** Two ops because the Websites row is split across two Turso tables. Callers\n * pass the EXACT FieldSet the Airtable writer returned, so the mirror can never\n * carry a different payload than the write it shadows. */\nexport type SiteMirror = {\n /** A row Airtable just CREATED, as Airtable echoed it back (`ensure-site`).\n * Every other op is an UPDATE, which does nothing for a row that does not\n * exist yet — so without this a bootstrapped site is invisible to Turso and\n * every mirror the rest of the bootstrap fires reports `mirrored=missed`. */\n created: (rec: { id: string; fields: Record<string, unknown> }) => Promise<void>;\n /** Columns that live in `site_health`. */\n health: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n /** Columns that live in `sites`. */\n site: (siteId: string, fields: Record<string, unknown>) => Promise<void>;\n};\n\n/** Build the one-off writers' mirror. Never throws and never returns null:\n * Airtable is still authoritative through Phase 5, so a mirror problem must not\n * cost the write it shadows — the hourly sync converges whatever this misses.\n * `open` is injectable for tests. */\nexport async function makeSiteMirror(\n open: () => Promise<Db> = () => openDb(readDbConfig()),\n /** #612. `true` = Turso is the store that must succeed, so every failure\n * throws instead of being logged and swallowed. Defaulted from the shipped\n * constant and injected by tests, so both sides stay proven. */\n strict: boolean = TURSO_IS_AUTHORITATIVE,\n): Promise<SiteMirror> {\n let db: Db | null = null;\n let why = \"\";\n try {\n db = await open();\n } catch (e) {\n why = (e as Error).message;\n }\n // Frozen: refuse to hand back a mirror that cannot write. Failing at\n // CONSTRUCTION rather than per write matters — a caller holding a\n // working-looking mirror would run its whole batch before anyone noticed that\n // nothing had persisted.\n if (strict && !db) throw new Error(`SITE_MIRROR unavailable: ${why}`);\n\n const run = async (siteId: string, op: string, work: (db: Db) => Promise<boolean>) => {\n if (!db) {\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=absent reason=${why}`);\n return;\n }\n // Every path below logs EXACTLY ONE line, then strict adds a throw. Logging\n // first matters: a frozen run that fails should still leave the same trace\n // in the run log that an unfrozen one would, not just a stack.\n let matched: boolean;\n try {\n matched = await work(db);\n } catch (e) {\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=0 error=${(e as Error).message}`);\n if (strict) throw e;\n return;\n }\n // `missed` is its own outcome: the UPDATE matched no row. Before the freeze\n // that means the hourly sync has not imported this site yet — a transient,\n // and reporting it as mirrored=1 would claim a write that never landed.\n // After the freeze no importer exists, so an absent row stays absent: it is\n // a bug, not a wait.\n console.log(`SITE_MIRROR site=${siteId} op=${op} mirrored=${matched ? \"1\" : \"missed\"}`);\n if (strict && !matched) {\n throw new Error(`SITE_MIRROR site=${siteId} op=${op}: no such row in Turso`);\n }\n };\n\n return {\n // An INSERT always lands, so it reports 1 rather than routing a row count\n // through `missed` — there was nothing to match in the first place.\n created: (rec) =>\n run(rec.id, \"created\", async (d) => {\n await mirrorSiteInsert(d, rec, new Date().toISOString());\n return true;\n }),\n health: (siteId, fields) => run(siteId, \"health\", (d) => mirrorHealthFields(d, siteId, fields)),\n site: (siteId, fields) => run(siteId, \"site\", (d) => mirrorSiteFields(d, siteId, fields)),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2CA,eAAsB,eACpB,OAA0B,MAAM,OAAO,aAAa,CAAC,GAIrD,SAAkB,wBACG;AACrB,MAAI,KAAgB;AACpB,MAAI,MAAM;AACV,MAAI;AACF,SAAK,MAAM,KAAK;AAAA,EAClB,SAAS,GAAG;AACV,UAAO,EAAY;AAAA,EACrB;AAKA,MAAI,UAAU,CAAC,GAAI,OAAM,IAAI,MAAM,4BAA4B,GAAG,EAAE;AAEpE,QAAM,MAAM,OAAO,QAAgB,IAAY,SAAuC;AACpF,QAAI,CAAC,IAAI;AACP,cAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,2BAA2B,GAAG,EAAE;AAC/E;AAAA,IACF;AAIA,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,KAAK,EAAE;AAAA,IACzB,SAAS,GAAG;AACV,cAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,qBAAsB,EAAY,OAAO,EAAE;AAC1F,UAAI,OAAQ,OAAM;AAClB;AAAA,IACF;AAMA,YAAQ,IAAI,oBAAoB,MAAM,OAAO,EAAE,aAAa,UAAU,MAAM,QAAQ,EAAE;AACtF,QAAI,UAAU,CAAC,SAAS;AACtB,YAAM,IAAI,MAAM,oBAAoB,MAAM,OAAO,EAAE,wBAAwB;AAAA,IAC7E;AAAA,EACF;AAEA,SAAO;AAAA;AAAA;AAAA,IAGL,SAAS,CAAC,QACR,IAAI,IAAI,IAAI,WAAW,OAAO,MAAM;AAClC,YAAM,iBAAiB,GAAG,MAAK,oBAAI,KAAK,GAAE,YAAY,CAAC;AACvD,aAAO;AAAA,IACT,CAAC;AAAA,IACH,QAAQ,CAAC,QAAQ,WAAW,IAAI,QAAQ,UAAU,CAAC,MAAM,mBAAmB,GAAG,QAAQ,MAAM,CAAC;AAAA,IAC9F,MAAM,CAAC,QAAQ,WAAW,IAAI,QAAQ,QAAQ,CAAC,MAAM,iBAAiB,GAAG,QAAQ,MAAM,CAAC;AAAA,EAC1F;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/db/submissions.ts"],"sourcesContent":["import { sql } from \"kysely\";\nimport type { Selectable, SqlBool } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { SubmissionsTable } from \"./schema.js\";\nimport {\n type SubmissionRow,\n type SubmissionInput,\n toFormType,\n toStatus,\n toNotifyStatus,\n} from \"../reports/submission-row.js\";\nimport type { FormType, SubmissionStatus } from \"../reports/submission-row.js\";\nimport { SIGNUP_FORM_TYPES } from \"../forms/types.js\";\n\nexport type SubmissionFilter = {\n siteId?: string;\n formType?: FormType;\n status?: SubmissionStatus;\n /** LIKE %q% across name/email/message/phone, case-insensitive */\n search?: string;\n /** submitted_at >= from (ISO string) */\n from?: string;\n /** submitted_at <= to (ISO string) */\n to?: string;\n /** exact spam_reason token (comma-boundary match; a stored \":N\" count suffix on\n * the token still matches) */\n reason?: string;\n};\n\n/** Map a raw DB row to the canonical SubmissionRow, narrowing the enum columns\n * with the SAME validators the Airtable mapRow uses — SQLite stores TEXT, so a\n * bad stored value must still be defended against. */\nfunction rowFromDb(r: Selectable<SubmissionsTable>): SubmissionRow {\n return {\n id: r.id,\n submissionId: r.submission_id,\n siteId: r.site_id,\n formType: toFormType(r.form_type),\n name: r.name,\n email: r.email,\n phone: r.phone,\n message: r.message,\n extraFields: r.extra_fields,\n sourceUrl: r.source_url,\n utm: r.utm,\n submittedAt: r.submitted_at,\n status: toStatus(r.status),\n notifyStatus: toNotifyStatus(r.notify_status),\n resendMessageId: r.resend_message_id,\n spamScore: typeof r.spam_score === \"number\" ? r.spam_score : null,\n spamReason: r.spam_reason,\n fanoutStatus: r.fanout_status,\n };\n}\n\n/** Opaque, collision-free id. crypto is a Node 20 global — no new dep. */\nexport function newSubmissionId(): string {\n return `sub_${crypto.randomUUID()}`;\n}\n\nexport async function getSubmissionById(db: Db, id: string): Promise<SubmissionRow | null> {\n const r = await db.selectFrom(\"submissions\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? rowFromDb(r) : null;\n}\n\nexport async function createSubmission(db: Db, input: SubmissionInput): Promise<SubmissionRow> {\n const id = newSubmissionId();\n const extra =\n input.extraFields !== undefined && Object.keys(input.extraFields).length > 0\n ? JSON.stringify(input.extraFields)\n : null;\n await db\n .insertInto(\"submissions\")\n .values({\n id,\n // Display number: MAX+1 in a single statement. libSQL is single-writer, so\n // writes serialize and this is race-free.\n submission_id: sql<number>`(SELECT COALESCE(MAX(submission_id), 0) + 1 FROM submissions)`,\n site_id: input.siteId,\n form_type: input.formType,\n name: input.name,\n email: input.email,\n phone: input.phone ?? null,\n message: input.message ?? null,\n extra_fields: extra,\n source_url: input.sourceUrl ?? null,\n utm: input.utm ?? null,\n submitted_at: input.submittedAt.toISOString(),\n status: input.status ?? \"new\",\n notify_status: \"skipped\",\n resend_message_id: null,\n spam_score: input.spamScore ?? null,\n spam_reason: input.spamReason ?? null,\n // Always null at insert: the fan-out runs after the row exists (stampFanout).\n fanout_status: null,\n })\n .execute();\n const created = await getSubmissionById(db, id);\n if (!created) throw new Error(\"createSubmission: row vanished after insert\");\n return created;\n}\n\nimport type { NotifyStatus } from \"../reports/submission-row.js\";\n\nexport async function listNewSubmissions(db: Db, max = 200): Promise<SubmissionRow[]> {\n // Bound the fetch (matches listSubmissionsForSite's default): the cockpit loads this whole\n // array on every render. Unbounded, it deserializes every unread submission fleet-wide. The\n // badge count therefore caps at `max` — acceptable, since >200 unread fleet-wide is itself a\n // triage emergency, and the /submissions page is the unbounded, filterable view.\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"status\", \"=\", \"new\")\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\n/** Same signature shape as the Airtable version (takes `{ id, name }`) so the\n * composition-root swap is import-only — but here we filter by id directly, with\n * no linked-field/primary-field workaround and no JS-confirm pass. */\nexport async function listSubmissionsForSite(\n db: Db,\n site: { id: string; name: string },\n max = 200,\n): Promise<SubmissionRow[]> {\n const rows = await db\n .selectFrom(\"submissions\")\n .selectAll()\n .where(\"site_id\", \"=\", site.id)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(max)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function setSubmissionStatusRow(\n db: Db,\n id: string,\n status: SubmissionStatus,\n): Promise<void> {\n await db.updateTable(\"submissions\").set({ status }).where(\"id\", \"=\", id).execute();\n}\n\nexport async function stampNotified(\n db: Db,\n id: string,\n status: NotifyStatus,\n messageId: string | null,\n): Promise<void> {\n const patch =\n messageId !== null\n ? { notify_status: status, resend_message_id: messageId }\n : { notify_status: status };\n await db.updateTable(\"submissions\").set(patch).where(\"id\", \"=\", id).execute();\n}\n\n/** Record the newsletter fan-out outcome on a row (comma-joined destination tokens,\n * see ingest.ts). REPLACES any previous value — the fan-out runs once per submission,\n * so the last write is the whole truth; unlike markSubmissionsSpamRetro there is no\n * earlier trail worth appending to. No status guard: this is provenance about what\n * the pipeline did, not a triage decision, so it stays accurate even for a row the\n * operator has already read or archived. */\nexport async function stampFanout(db: Db, id: string, fanoutStatus: string): Promise<void> {\n await db\n .updateTable(\"submissions\")\n .set({ fanout_status: fanoutStatus })\n .where(\"id\", \"=\", id)\n .execute();\n}\n\n// NOTE: listSubmissionsFiltered and countSubmissionsFiltered share identical filter\n// logic via applySubmissionFilter — always update both through that helper.\nfunction applySubmissionFilter<O>(\n qb: import(\"kysely\").SelectQueryBuilder<import(\"./schema.js\").Database, \"submissions\", O>,\n f: SubmissionFilter,\n) {\n let q = qb;\n if (f.siteId !== undefined) q = q.where(\"site_id\", \"=\", f.siteId);\n if (f.formType !== undefined) q = q.where(\"form_type\", \"=\", f.formType);\n if (f.status !== undefined) q = q.where(\"status\", \"=\", f.status);\n if (f.from !== undefined) q = q.where(\"submitted_at\", \">=\", f.from);\n if (f.to !== undefined) q = q.where(\"submitted_at\", \"<=\", f.to);\n if (f.search !== undefined && f.search.trim() !== \"\") {\n // Escape LIKE metacharacters so a user's literal `%`/`_` (or the escape char `\\`) match\n // literally rather than as wildcards: searching `john_doe` must not also hit `johnXdoe`,\n // and a bare `%` must not match everything. `\\` is escaped first (it's the ESCAPE char).\n // Parameterized regardless (no injection) — this is a correctness fix. Uses sql`` because\n // kysely's `like` operator can't attach an ESCAPE clause.\n const term = f.search\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n const like = `%${term}%`;\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`lower(${eb.ref(\"name\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"email\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"message\")}) like ${like} escape '\\\\'`,\n sql<SqlBool>`lower(${eb.ref(\"phone\")}) like ${like} escape '\\\\'`,\n ]),\n );\n }\n if (f.reason !== undefined && f.reason.trim() !== \"\") {\n // spam_reason is a comma-joined token list (ingest.ts joins \",\"; retro appends\n // \",<reason>\"). Comma-boundary match so \"links\" never matches \"backlinks\" and\n // \"turnstile-required-absent\" never matches \"-failed\"; the second branch accepts\n // a per-token count suffix (\"keywords:4\" matches reason=keywords). LIKE\n // metacharacters escaped exactly like the search branch. lower() both sides is\n // safe: tokens are ASCII classifier codes.\n const token = f.reason\n .trim()\n .toLowerCase()\n .replace(/[\\\\%_]/g, \"\\\\$&\");\n q = q.where((eb) =>\n eb.or([\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token},%`} escape '\\\\'`,\n sql<SqlBool>`',' || lower(${eb.ref(\"spam_reason\")}) || ',' like ${`%,${token}:%`} escape '\\\\'`,\n ]),\n );\n }\n return q;\n}\n\nexport async function listSubmissionsFiltered(\n db: Db,\n filter: SubmissionFilter,\n opts: { limit: number; offset: number },\n): Promise<SubmissionRow[]> {\n const base = db.selectFrom(\"submissions\").selectAll();\n const rows = await applySubmissionFilter(base, filter)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(opts.limit)\n .offset(opts.offset)\n .execute();\n return rows.map(rowFromDb);\n}\n\nexport async function countSubmissionsFiltered(db: Db, filter: SubmissionFilter): Promise<number> {\n const base = db.selectFrom(\"submissions\").select((eb) => eb.fn.countAll<number>().as(\"n\"));\n const res = await applySubmissionFilter(base, filter).executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\n/** Fleet-wide count of auto-filtered spam (`status = 'spam_auto'`) submitted on/after\n * `sinceDate` (ISO). Powers the cockpit \"N auto-filtered this week — review\" affordance;\n * the caller picks the window (like `listScreenOutsSince`), keeping this query pure. */\nexport async function countAutoSpamSince(db: Db, sinceDate: string): Promise<number> {\n const res = await db\n .selectFrom(\"submissions\")\n .select((eb) => eb.fn.countAll<number>().as(\"n\"))\n .where(\"status\", \"=\", \"spam_auto\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .executeTakeFirstOrThrow();\n return Number(res.n);\n}\n\nexport type SiteSubmissionCounts = { leads: number; signups: number; spamAuto: number };\n\n/** Per-site submission counts on/after `sinceIso` (full ISO timestamp) for the\n * digest telemetry line. Grouped one-query: leads/signups = rows NOT auto- or\n * operator-bucketed as spam (status NOT IN ('spam_auto','spam')) split by\n * isLeadFormType; spamAuto = status='spam_auto'. Keyed by Websites record id. */\nexport async function countSubmissionsSinceBySite(\n db: Db,\n sinceIso: string,\n): Promise<Map<string, SiteSubmissionCounts>> {\n const signupList = sql.join(SIGNUP_FORM_TYPES.map((t) => sql`${t}`));\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type not in (${signupList}) then 1 else 0 end)`.as(\n \"leads_n\",\n ),\n sql<number>`sum(case when status not in ('spam_auto','spam') and form_type in (${signupList}) then 1 else 0 end)`.as(\n \"signups_n\",\n ),\n sql<number>`sum(case when status = 'spam_auto' then 1 else 0 end)`.as(\"spam_auto_n\"),\n ])\n .where(\"submitted_at\", \">=\", sinceIso)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n {\n leads: Number(r.leads_n) || 0,\n signups: Number(r.signups_n) || 0,\n spamAuto: Number(r.spam_auto_n) || 0,\n },\n ]),\n );\n}\n\n/** A normalized body shorter than this never triggers the exact-duplicate signal —\n * short/boilerplate lines (a newsletter \"subscribe\", \"hi\") legitimately repeat\n * across real people. */\nexport const MIN_DUP_BODY_LEN = 40;\n\n/** Both token sets must be at least this large before the Jaccard tier can fire —\n * two short genuine messages (\"what are your hours?\" variants) share most of their\n * few tokens and would otherwise collide. */\nexport const MIN_SIMILAR_TOKENS = 25;\n\n/** Jaccard similarity (token sets) at or above this marks a near-duplicate. 0.9 is\n * deliberately strict: a template spray with one substituted greeting/target still\n * clears it, while two independently-written enquiries about the same business\n * never do. */\nexport const SIMILARITY_THRESHOLD = 0.9;\n\n/** Fold a message body down to its template skeleton. Full-Unicode lowercase first\n * (JS `toLowerCase()` — SQLite's `lower()` is ASCII-only, which is why this whole\n * comparison lives in JS, folding BOTH sides identically so byte-identical Cyrillic\n * copies keep matching), then strip everything a spray substitutes per target —\n * URLs, emails, bare domains, digit runs — and collapse whitespace. Over-stripping\n * is safe: it happens symmetrically on both sides. */\nfunction normalizeBody(body: string): string {\n return body\n .toLowerCase()\n .replace(/https?:\\/\\/\\S+/gu, \" \") // full URLs\n .replace(/www\\.\\S+/gu, \" \") // scheme-less www. links\n .replace(/\\S+@\\S+/gu, \" \") // email addresses\n .replace(/\\b[a-z0-9-]+(\\.[a-z0-9-]+)+\\b/gu, \" \") // bare domain tokens (word.tld)\n .replace(/\\d+/gu, \" \") // digit runs (phone numbers, prices, years)\n .replace(/\\s+/gu, \" \")\n .trim();\n}\n\n/** Letter-only token set of a normalized body (punctuation/symbols are separators). */\nfunction tokenSet(normalized: string): Set<string> {\n return new Set(normalized.split(/[^\\p{L}]+/u).filter((t) => t.length > 0));\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n let intersection = 0;\n for (const t of a) if (b.has(t)) intersection++;\n const union = a.size + b.size - intersection;\n return union === 0 ? 0 : intersection / union;\n}\n\n/** Fleet-wide duplicate/near-duplicate lookup for the spray spam signal — the same\n * pitch blasted across sites shows up as identical OR near-identical bodies (the\n * live dog-harness spray differed ONLY in greeting; SEO sprays substitute the\n * target domain per site — exact match alone missed both). Scans rows submitted\n * on/after `sinceDate` and compares in JS (see normalizeBody for why not SQL):\n * - `exact`: normalized equality, only when the normalized incoming body is at\n * least MIN_DUP_BODY_LEN chars.\n * - `similar`: token-set Jaccard >= SIMILARITY_THRESHOLD, only when BOTH sets have\n * at least MIN_SIMILAR_TOKENS tokens. A row matched as exact never re-appears here.\n * Returned statuses let the caller retro-bucket still-'new' prior copies. The 2000-row\n * LIMIT is a safety bound only — current volume is ~130/month, so a 30-day window is\n * always a full scan. */\n/** One matched prior row from the duplicate scan. Carries siteId + email so the\n * ingest caller can exempt a GENUINE same-sender resubmission on the same site\n * (a real visitor double-submitting / resending after silence) — only cross-site\n * or different-sender copies are spray evidence. */\nexport type DuplicateMatch = { id: string; status: string; siteId: string; email: string };\n\nexport async function findRecentDuplicateSubmissions(\n db: Db,\n message: string,\n sinceDate: string,\n): Promise<{\n exact: DuplicateMatch[];\n similar: DuplicateMatch[];\n}> {\n const incoming = normalizeBody(message);\n const incomingTokens = tokenSet(incoming);\n const exactEligible = incoming.length >= MIN_DUP_BODY_LEN;\n const similarEligible = incomingTokens.size >= MIN_SIMILAR_TOKENS;\n // Too short for either tier — skip the scan entirely (nothing could match).\n if (!exactEligible && !similarEligible) return { exact: [], similar: [] };\n\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"status\", \"message\", \"site_id\", \"email\"])\n .where(\"message\", \"is not\", null)\n .where(\"submitted_at\", \">=\", sinceDate)\n .orderBy(\"submitted_at\", \"desc\")\n .limit(2000)\n .execute();\n\n const exact: DuplicateMatch[] = [];\n const similar: DuplicateMatch[] = [];\n for (const row of rows) {\n const stored = normalizeBody(row.message ?? \"\");\n const match: DuplicateMatch = {\n id: row.id,\n status: row.status,\n siteId: row.site_id,\n email: row.email,\n };\n if (exactEligible && stored === incoming) {\n exact.push(match);\n continue; // an exact match must not also appear in similar\n }\n if (!similarEligible) continue;\n const storedTokens = tokenSet(stored);\n if (storedTokens.size < MIN_SIMILAR_TOKENS) continue;\n if (jaccard(incomingTokens, storedTokens) >= SIMILARITY_THRESHOLD) {\n similar.push(match);\n }\n }\n return { exact, similar };\n}\n\n/** Recent non-newsletter submissions from `email` (case/whitespace-folded), on/after\n * `sinceDate` (ISO). Powers the cross-site repeat-sender signal: the fleet's sites\n * are UNRELATED businesses, so the same address writing to 2+ of them inside a month\n * is a solicitation tell — the caller compares siteIds (same-site repeats are genuine\n * follow-ups). Newsletter rows are excluded: one person subscribing on two sites is\n * legitimate. Both sides fold in SQL (same-side folding, see the normalizeBody note). */\nexport async function listRecentSubmissionsForEmail(\n db: Db,\n email: string,\n sinceDate: string,\n): Promise<Array<{ id: string; siteId: string; status: string }>> {\n if (email.trim() === \"\") return [];\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"id\", \"site_id\", \"status\"])\n .where(\"form_type\", \"!=\", \"newsletter\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where((eb) => sql<SqlBool>`lower(trim(${eb.ref(\"email\")})) = lower(trim(${email}))`)\n .execute();\n return rows.map((r) => ({ id: r.id, siteId: r.site_id, status: r.status }));\n}\n\n/** Retroactively re-bucket prior spray copies once a later copy identifies the spray\n * (the FIRST copy is always delivered by design — only a repeat reveals it). Appends\n * `retroReason` to any existing spam_reason so the original classifier trail survives.\n * The `status = 'new'` guard is load-bearing: rows the operator already read/replied/\n * archived/marked are NEVER touched — this only cleans the unread queue. */\nexport async function markSubmissionsSpamRetro(\n db: Db,\n ids: string[],\n retroReason: string,\n): Promise<void> {\n if (ids.length === 0) return;\n await db\n .updateTable(\"submissions\")\n .set({\n status: \"spam_auto\",\n spam_reason: sql<string>`CASE WHEN spam_reason IS NULL OR spam_reason = '' THEN ${retroReason} ELSE spam_reason || ',' || ${retroReason} END`,\n })\n .where(\"id\", \"in\", ids)\n .where(\"status\", \"=\", \"new\")\n .execute();\n}\n\n/** spam_reason strings for EVERY row matching `filter` (not just one page). Powers the\n * /submissions per-reason facet line, which must tally the WHOLE filtered bucket — the\n * rollout runbook directs the operator to judge the canary from it, and a page-scoped\n * tally under the full-bucket total silently misread once the bucket passed one page.\n * Capped (default 2000) as a safety bound far above any real bucket. */\nexport async function listSpamReasonsFiltered(\n db: Db,\n filter: SubmissionFilter,\n cap = 2000,\n): Promise<string[]> {\n const base = db.selectFrom(\"submissions\").select([\"spam_reason\"]);\n const rows = await applySubmissionFilter(base, filter)\n .where(\"spam_reason\", \"is not\", null)\n .limit(cap)\n .execute();\n return rows.map((r) => r.spam_reason).filter((r): r is string => r !== null && r !== \"\");\n}\n\n/** Insert a SubmissionRow verbatim, preserving its id, display number, and status.\n * ON CONFLICT(id) DO NOTHING makes the whole backfill re-runnable. */\nexport async function backfillSubmission(db: Db, row: SubmissionRow): Promise<void> {\n await db\n .insertInto(\"submissions\")\n .values({\n id: row.id,\n submission_id: row.submissionId,\n site_id: row.siteId,\n form_type: row.formType,\n name: row.name,\n email: row.email,\n phone: row.phone,\n message: row.message,\n extra_fields: row.extraFields,\n source_url: row.sourceUrl,\n utm: row.utm,\n submitted_at: row.submittedAt,\n status: row.status,\n notify_status: row.notifyStatus,\n resend_message_id: row.resendMessageId,\n spam_score: row.spamScore ?? null,\n spam_reason: row.spamReason ?? null,\n fanout_status: row.fanoutStatus ?? null,\n })\n .onConflict((oc) => oc.column(\"id\").doNothing())\n .execute();\n}\n\n/** Re-score write for the `submissions rescore` CLI (2026-07-16): flip ONE still-'new'\n * row to spam_auto with the CURRENT classifier's verdict. Unlike markSubmissionsSpamRetro\n * (which appends to the old trail), this REPLACES spam_score/spam_reason — the point of a\n * re-score is that today's verdict supersedes the stale ingest-time one; the caller bakes\n * its provenance marker (retro-rescore) into `reason`. The status='new' guard is\n * load-bearing: rows the operator already read/replied/archived/marked are NEVER\n * re-bucketed. Returns true when the row was written (false = no longer 'new'). */\nexport async function rescoreSubmissionSpam(\n db: Db,\n id: string,\n score: number,\n reason: string,\n): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"spam_auto\", spam_score: score, spam_reason: reason })\n .where(\"id\", \"=\", id)\n .where(\"status\", \"=\", \"new\")\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Bulk triage for the /submissions page (2026-07-16): flip EVERY still-'new' row\n * matching `filter` to 'read', server-side, so a 100-row backlog is one click instead\n * of 100. Reuses applySubmissionFilter (via an id-subquery — kysely's update builder\n * can't take the select-only helper directly) so the POST flips exactly the bucket the\n * GET rendered. The outer status='new' guard is load-bearing: spam/archived/read rows\n * are never touched even when the filter matches them (bulk-\"reading\" a spam_auto row\n * would resurrect it into the per-site strip). Returns the number of rows flipped. */\nexport async function markFilteredAsRead(db: Db, filter: SubmissionFilter): Promise<number> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ status: \"read\" })\n .where(\"status\", \"=\", \"new\")\n .where(\"id\", \"in\", (eb) =>\n applySubmissionFilter(eb.selectFrom(\"submissions\").select(\"id\"), filter),\n )\n .executeTakeFirst();\n return Number(res.numUpdatedRows);\n}\n\n/** Flip a submission's notify_status to 'bounced' by its Resend message id — the\n * resend-webhook's mapping from a bounce/complaint event back onto the lead whose\n * notification it was (2026-07-16, the Espada failure mode: 'sent' only means Resend\n * ACCEPTED the email). Returns whether any row matched, so the webhook can tell a\n * submission notification apart from a report email (unknown id → false → the report\n * path handles it). Idempotent: a svix replay re-writes the same terminal value. In\n * practice only 'sent' rows can match — stampNotified stamps the message id and the\n * 'sent' status together, and failed/skipped rows carry no id. */\nexport async function markNotifyBouncedByMessageId(db: Db, messageId: string): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set({ notify_status: \"bounced\" })\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n if (Number(res.numUpdatedRows) > 0) return true;\n // A replayed webhook must still report \"this was a submission\" (200, stop svix\n // retrying) even though the terminal value is already written — matched-but-\n // unchanged rows still count in numUpdatedRows on SQLite, but don't rely on it.\n const existing = await db\n .selectFrom(\"submissions\")\n .select(\"id\")\n .where(\"resend_message_id\", \"=\", messageId)\n .executeTakeFirst();\n return existing !== undefined;\n}\n\n/** Per-site counts of bounced lead notifications on/after `sinceDate` (ISO), keyed by\n * the Websites record id (`site_id`). Powers the notify-bounce attention collector —\n * the caller picks the window (like `countAutoSpamSince`), keeping this query pure.\n * Windowed on `submitted_at`: the bounce lands minutes after the submission, and\n * submissions carry no bounce timestamp column (append-only schema, 2026-07-16). */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, number>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\"site_id\", (eb) => eb.fn.countAll<number>().as(\"n\")])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .groupBy(\"site_id\")\n .execute();\n return new Map(rows.map((r) => [r.site_id, Number(r.n)]));\n}\n"],"mappings":"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","names":[]}
|