@reddoorla/maintenance 0.95.1 → 0.96.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 +37 -2
- package/dist/airtable-SJV4ZSMS.js +9 -0
- package/dist/{announce-CLQYX5I4.js → announce-45HVP4J4.js} +7 -7
- package/dist/{blux-4KNXUP2K.js → blux-RUFMNOUJ.js} +5 -5
- package/dist/{bump-deps-C5G2EFBB.js → bump-deps-EL3TKMIL.js} +6 -6
- package/dist/{chunk-XN37VTDB.js → chunk-37B4LVB3.js} +2 -2
- package/dist/{chunk-BVEDKMNG.js → chunk-3EA43S3R.js} +28 -10
- package/dist/chunk-3EA43S3R.js.map +1 -0
- package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
- package/dist/chunk-43SW3FGW.js.map +1 -0
- package/dist/{chunk-WMBLR5VA.js → chunk-44SDGPGQ.js} +3 -3
- package/dist/chunk-4UTJ4G6P.js +52 -0
- package/dist/chunk-4UTJ4G6P.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-KJRVMMM3.js → chunk-62YWOFVN.js} +36 -5
- package/dist/chunk-62YWOFVN.js.map +1 -0
- package/dist/{chunk-VSGMCCGQ.js → chunk-6INOBNRO.js} +2 -2
- package/dist/{chunk-BYGPUUCT.js → chunk-72FJZ6LO.js} +43 -11
- package/dist/chunk-72FJZ6LO.js.map +1 -0
- package/dist/{chunk-5GUJ36TL.js → chunk-74QRWE6G.js} +2 -2
- package/dist/chunk-A5QKNOXW.js +72 -0
- package/dist/chunk-A5QKNOXW.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-S7RYFUIK.js → chunk-CA6ADGOC.js} +2 -2
- package/dist/chunk-CGQZFQAY.js +38 -0
- package/dist/chunk-CGQZFQAY.js.map +1 -0
- package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
- package/dist/chunk-CXICCLNM.js.map +1 -0
- package/dist/{chunk-KZRR4NFG.js → chunk-D3CTOCUR.js} +3 -3
- package/dist/{chunk-CWXTNPNZ.js → chunk-DVJNYPFR.js} +3 -3
- package/dist/chunk-DVJNYPFR.js.map +1 -0
- package/dist/{chunk-AVEQUR44.js → chunk-EASYCD6F.js} +60 -17
- package/dist/chunk-EASYCD6F.js.map +1 -0
- package/dist/{chunk-BCKEP5S6.js → chunk-ECDJDHKT.js} +2 -2
- package/dist/{chunk-BI2VI6EM.js → chunk-EQQ4H6L3.js} +2 -2
- package/dist/{chunk-WVNDEYXL.js → chunk-GEUC5VGM.js} +11 -5
- package/dist/chunk-GEUC5VGM.js.map +1 -0
- package/dist/{chunk-Z2K4JPQ3.js → chunk-H445EH33.js} +2 -2
- package/dist/chunk-IHHAYXKY.js +351 -0
- package/dist/chunk-IHHAYXKY.js.map +1 -0
- package/dist/{chunk-FUWFLAWQ.js → chunk-IMYL67JV.js} +3 -3
- package/dist/{chunk-3Z6X3BHU.js → chunk-KN52PM63.js} +7 -2
- package/dist/{chunk-3Z6X3BHU.js.map → chunk-KN52PM63.js.map} +1 -1
- package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
- package/dist/chunk-LAJ3FF54.js.map +1 -0
- package/dist/{chunk-5UINJNAJ.js → chunk-MZZDMBDT.js} +16 -3
- package/dist/chunk-MZZDMBDT.js.map +1 -0
- package/dist/{chunk-DFKBTBO3.js → chunk-NVLREB5R.js} +35 -6
- package/dist/chunk-NVLREB5R.js.map +1 -0
- package/dist/{chunk-R5IMFAFR.js → chunk-OTEVKRAH.js} +3 -3
- package/dist/{chunk-2ZAZ7ETT.js → chunk-P4A6D6HO.js} +4 -4
- package/dist/{chunk-XPJQXSC4.js → chunk-P7323YVW.js} +27 -5
- package/dist/chunk-P7323YVW.js.map +1 -0
- package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
- package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
- package/dist/chunk-QFSL2L2B.js.map +1 -0
- package/dist/{chunk-2LYOAB2E.js → chunk-RRE74VWI.js} +2 -2
- package/dist/{chunk-LM2NHRCJ.js → chunk-SHGIDTYF.js} +4 -4
- package/dist/chunk-SHGIDTYF.js.map +1 -0
- package/dist/{chunk-ZQV7UQNE.js → chunk-SQ4FSNYL.js} +2 -2
- package/dist/{chunk-C3IQQKXL.js → chunk-T76MA2FH.js} +110 -1
- package/dist/{chunk-C3IQQKXL.js.map → chunk-T76MA2FH.js.map} +1 -1
- package/dist/{chunk-6R56OKJZ.js → chunk-UIITN37J.js} +2 -2
- package/dist/{chunk-CJ2RMTT7.js → chunk-VEIIUCYL.js} +2 -2
- 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-WSJ7RO54.js → chunk-XED2DNAI.js} +23 -5
- package/dist/chunk-XED2DNAI.js.map +1 -0
- package/dist/{chunk-FSGICFXB.js → chunk-XG2A6GIG.js} +17 -5
- package/dist/chunk-XG2A6GIG.js.map +1 -0
- package/dist/{chunk-TO7DJYLZ.js → chunk-XY7YV2YQ.js} +178 -39
- package/dist/chunk-XY7YV2YQ.js.map +1 -0
- package/dist/{chunk-HTABUCF4.js → chunk-Y5BVR4HW.js} +3 -3
- 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-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
- package/dist/chunk-Z7FDJ35L.js.map +1 -0
- package/dist/{chunk-5EEMTJMT.js → chunk-ZH5LOQET.js} +4 -4
- package/dist/cli/bin.js +49 -31
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +18 -18
- package/dist/client-L7NAUG6J.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-4DON7IFS.js} +6 -6
- package/dist/{db-XKZYDCKM.js → db-5K4CDEGJ.js} +45 -20
- package/dist/db-5K4CDEGJ.js.map +1 -0
- package/dist/deadletter-G4M3HSVM.js +17 -0
- package/dist/{digest-7UBGUBRK.js → digest-5AJGWS33.js} +45 -22
- package/dist/digest-5AJGWS33.js.map +1 -0
- package/dist/{digest-collectors-DJFN5ZDK.js → digest-collectors-MC5NWEUA.js} +12 -10
- 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-OAAE5YE2.js → ensure-site-XESZHZEB.js} +39 -11
- package/dist/ensure-site-XESZHZEB.js.map +1 -0
- package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-GJWGKTLD.js} +6 -4
- package/dist/{forms-notify-target-UC6PF3AE.js → forms-notify-target-4NPLX2UG.js} +5 -5
- package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
- package/dist/{github-signals-TX5FPYP6.js → github-signals-DMU74SAX.js} +10 -10
- package/dist/{header-image-PSPT7CUM.js → header-image-BRTOLHJF.js} +6 -5
- package/dist/header-image-BRTOLHJF.js.map +1 -0
- package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-UCLMSSCN.js} +8 -7
- package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-UCLMSSCN.js.map} +1 -1
- package/dist/{health-mirror-XJAKA3EY.js → health-mirror-ONRT4DQ5.js} +7 -7
- package/dist/{import-airtable-TAKSN4NV.js → import-airtable-IIK2QUUF.js} +3 -3
- package/dist/index.d.ts +135 -4
- package/dist/index.js +68 -34
- package/dist/index.js.map +1 -1
- package/dist/{init-FFE4FCO2.js → init-3LMZTCVD.js} +19 -18
- package/dist/{init-FFE4FCO2.js.map → init-3LMZTCVD.js.map} +1 -1
- package/dist/{launch-UD36AHUN.js → launch-XTDMC6LD.js} +209 -61
- package/dist/launch-XTDMC6LD.js.map +1 -0
- package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
- package/dist/{lighthouse-airtable-YMH4UA7R.js → lighthouse-airtable-3NOFB42B.js} +3 -3
- package/dist/{match-harness-QRG6XZAR.js → match-harness-OW45CB5H.js} +14 -7
- package/dist/match-harness-OW45CB5H.js.map +1 -0
- package/dist/migrate-NE3DUMTM.js +7 -0
- package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +3 -3
- package/dist/{onboard-TM2KESGF.js → onboard-CZYBWVWH.js} +6 -6
- package/dist/{orchestrate-HYVQPRCE.js → orchestrate-TGDFGY52.js} +8 -8
- package/dist/{parity-CAJ477PS.js → parity-AEKYSLN6.js} +5 -5
- package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
- package/dist/{preflight-RYIP54NY.js → preflight-POZZI3GN.js} +9 -9
- package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-AHY5ULJR.js} +38 -8
- package/dist/prismic-ci-AHY5ULJR.js.map +1 -0
- package/dist/{prismic-models-P6FWWVRS.js → prismic-models-URYGODNY.js} +9 -9
- package/dist/prospect/types.d.ts +72 -0
- package/dist/prospect/types.js +1 -1
- package/dist/{prospect-audit-PRHU53HS.js → prospect-audit-AJEE3Z4L.js} +16 -7
- package/dist/prospect-audit-AJEE3Z4L.js.map +1 -0
- package/dist/{prospect-audits-R3LWF6U7.js → prospect-audits-IZVQY3OQ.js} +4 -2
- package/dist/protection-audit-3J3UEOVT.js +413 -0
- package/dist/protection-audit-3J3UEOVT.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-65DC6ZP3.js} +4 -4
- package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-ONJMBUHJ.js} +3 -3
- package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
- package/dist/replay-4FVFOMHH.js.map +1 -0
- package/dist/{report-LQS4ECTM.js → report-RC5LGPU3.js} +22 -20
- package/dist/report-RC5LGPU3.js.map +1 -0
- package/dist/{report-mirror-HDTOJE64.js → report-mirror-KKZZ6RMZ.js} +13 -9
- package/dist/{report-mirror-HDTOJE64.js.map → report-mirror-KKZZ6RMZ.js.map} +1 -1
- 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-RKKIYNV4.js} +8 -7
- package/dist/{self-updating-MWXIZLL7.js.map → self-updating-RKKIYNV4.js.map} +1 -1
- package/dist/{selftest-VXTHRPRN.js → selftest-AFLR7YV7.js} +10 -10
- 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-H2FYMN55.js} +23 -9
- package/dist/site-mirror-H2FYMN55.js.map +1 -0
- package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-BWHR3XX5.js} +8 -7
- package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-BWHR3XX5.js.map} +1 -1
- package/dist/{submissions-OVZPVBYG.js → submissions-II5J35QM.js} +4 -4
- 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-QWGIRNN6.js} +6 -6
- package/dist/{sync-K3PKHVLH.js → sync-NKPDCMYW.js} +5 -5
- package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-HGLQV55H.js} +6 -6
- package/dist/{upgrade-VTD6V4HS.js → upgrade-WZ44IARG.js} +6 -6
- package/dist/util/git.d.ts +48 -9
- package/dist/util/git.js +1 -1
- package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
- package/dist/{websites-TB4TCAWM.js → websites-6DHIHJ2D.js} +4 -2
- package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-BIK6LNVC.js} +5 -5
- package/dist/write-audits-to-airtable-BIK6LNVC.js.map +1 -0
- package/package.json +3 -2
- package/dist/airtable-JGCPS7JH.js +0 -9
- package/dist/chunk-5UINJNAJ.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-FSGICFXB.js.map +0 -1
- package/dist/chunk-GASBX52O.js +0 -12
- package/dist/chunk-GASBX52O.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-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-XPJQXSC4.js.map +0 -1
- package/dist/chunk-YLYXW5PH.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/header-image-PSPT7CUM.js.map +0 -1
- package/dist/launch-UD36AHUN.js.map +0 -1
- package/dist/match-harness-QRG6XZAR.js.map +0 -1
- package/dist/migrate-Y7R4CESL.js +0 -7
- package/dist/prismic-ci-6GKKZGB5.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/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/{airtable-JGCPS7JH.js.map → airtable-SJV4ZSMS.js.map} +0 -0
- /package/dist/{announce-CLQYX5I4.js.map → announce-45HVP4J4.js.map} +0 -0
- /package/dist/{blux-4KNXUP2K.js.map → blux-RUFMNOUJ.js.map} +0 -0
- /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-EL3TKMIL.js.map} +0 -0
- /package/dist/{chunk-XN37VTDB.js.map → chunk-37B4LVB3.js.map} +0 -0
- /package/dist/{chunk-WMBLR5VA.js.map → chunk-44SDGPGQ.js.map} +0 -0
- /package/dist/{chunk-VSGMCCGQ.js.map → chunk-6INOBNRO.js.map} +0 -0
- /package/dist/{chunk-5GUJ36TL.js.map → chunk-74QRWE6G.js.map} +0 -0
- /package/dist/{chunk-S7RYFUIK.js.map → chunk-CA6ADGOC.js.map} +0 -0
- /package/dist/{chunk-KZRR4NFG.js.map → chunk-D3CTOCUR.js.map} +0 -0
- /package/dist/{chunk-BCKEP5S6.js.map → chunk-ECDJDHKT.js.map} +0 -0
- /package/dist/{chunk-BI2VI6EM.js.map → chunk-EQQ4H6L3.js.map} +0 -0
- /package/dist/{chunk-Z2K4JPQ3.js.map → chunk-H445EH33.js.map} +0 -0
- /package/dist/{chunk-FUWFLAWQ.js.map → chunk-IMYL67JV.js.map} +0 -0
- /package/dist/{chunk-R5IMFAFR.js.map → chunk-OTEVKRAH.js.map} +0 -0
- /package/dist/{chunk-2ZAZ7ETT.js.map → chunk-P4A6D6HO.js.map} +0 -0
- /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
- /package/dist/{chunk-2LYOAB2E.js.map → chunk-RRE74VWI.js.map} +0 -0
- /package/dist/{chunk-ZQV7UQNE.js.map → chunk-SQ4FSNYL.js.map} +0 -0
- /package/dist/{chunk-6R56OKJZ.js.map → chunk-UIITN37J.js.map} +0 -0
- /package/dist/{chunk-CJ2RMTT7.js.map → chunk-VEIIUCYL.js.map} +0 -0
- /package/dist/{chunk-HTABUCF4.js.map → chunk-Y5BVR4HW.js.map} +0 -0
- /package/dist/{chunk-5EEMTJMT.js.map → chunk-ZH5LOQET.js.map} +0 -0
- /package/dist/{client-G2WGWPCA.js.map → client-L7NAUG6J.js.map} +0 -0
- /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-4DON7IFS.js.map} +0 -0
- /package/dist/{digest-collectors-DJFN5ZDK.js.map → deadletter-G4M3HSVM.js.map} +0 -0
- /package/dist/{fleet-state-UEVZ3X3J.js.map → digest-collectors-MC5NWEUA.js.map} +0 -0
- /package/dist/{freeze-A6MUCZU4.js.map → fleet-state-GJWGKTLD.js.map} +0 -0
- /package/dist/{forms-notify-target-UC6PF3AE.js.map → forms-notify-target-4NPLX2UG.js.map} +0 -0
- /package/dist/{import-airtable-TAKSN4NV.js.map → freeze-GUQ43EAU.js.map} +0 -0
- /package/dist/{github-signals-TX5FPYP6.js.map → github-signals-DMU74SAX.js.map} +0 -0
- /package/dist/{health-mirror-XJAKA3EY.js.map → health-mirror-ONRT4DQ5.js.map} +0 -0
- /package/dist/{lighthouse-VHI77PFB.js.map → import-airtable-IIK2QUUF.js.map} +0 -0
- /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
- /package/dist/{migrate-Y7R4CESL.js.map → lighthouse-airtable-3NOFB42B.js.map} +0 -0
- /package/dist/{notify-IISTHNBR.js.map → migrate-NE3DUMTM.js.map} +0 -0
- /package/dist/{orchestrate-HYVQPRCE.js.map → notify-K3WVH3DM.js.map} +0 -0
- /package/dist/{onboard-TM2KESGF.js.map → onboard-CZYBWVWH.js.map} +0 -0
- /package/dist/{parity-CAJ477PS.js.map → orchestrate-TGDFGY52.js.map} +0 -0
- /package/dist/{pipeline-YROAY6DT.js.map → parity-AEKYSLN6.js.map} +0 -0
- /package/dist/{prospect-audits-R3LWF6U7.js.map → pipeline-UUPXOS2J.js.map} +0 -0
- /package/dist/{preflight-RYIP54NY.js.map → preflight-POZZI3GN.js.map} +0 -0
- /package/dist/{prismic-models-P6FWWVRS.js.map → prismic-models-URYGODNY.js.map} +0 -0
- /package/dist/{render-from-row-IFSBHWDH.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
- /package/dist/{websites-TB4TCAWM.js.map → render-from-row-65DC6ZP3.js.map} +0 -0
- /package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-ONJMBUHJ.js.map} +0 -0
- /package/dist/{selftest-VXTHRPRN.js.map → selftest-AFLR7YV7.js.map} +0 -0
- /package/dist/{submissions-OVZPVBYG.js.map → submissions-II5J35QM.js.map} +0 -0
- /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-QWGIRNN6.js.map} +0 -0
- /package/dist/{sync-K3PKHVLH.js.map → sync-NKPDCMYW.js.map} +0 -0
- /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-HGLQV55H.js.map} +0 -0
- /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-WZ44IARG.js.map} +0 -0
- /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → websites-6DHIHJ2D.js.map} +0 -0
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
toFormType,
|
|
3
3
|
toNotifyStatus,
|
|
4
4
|
toStatus
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-YDZ7XGPZ.js";
|
|
6
6
|
import {
|
|
7
7
|
SIGNUP_FORM_TYPES
|
|
8
8
|
} from "./chunk-2OP4JKIQ.js";
|
|
@@ -28,7 +28,11 @@ function rowFromDb(r) {
|
|
|
28
28
|
resendMessageId: r.resend_message_id,
|
|
29
29
|
spamScore: typeof r.spam_score === "number" ? r.spam_score : null,
|
|
30
30
|
spamReason: r.spam_reason,
|
|
31
|
-
fanoutStatus: r.fanout_status
|
|
31
|
+
fanoutStatus: r.fanout_status,
|
|
32
|
+
bounceType: r.bounce_type,
|
|
33
|
+
bounceSubType: r.bounce_subtype,
|
|
34
|
+
bounceMessage: r.bounce_message,
|
|
35
|
+
bounceAckAt: r.bounce_ack_at
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
function newSubmissionId() {
|
|
@@ -62,7 +66,13 @@ async function createSubmission(db, input) {
|
|
|
62
66
|
spam_score: input.spamScore ?? null,
|
|
63
67
|
spam_reason: input.spamReason ?? null,
|
|
64
68
|
// Always null at insert: the fan-out runs after the row exists (stampFanout).
|
|
65
|
-
fanout_status: null
|
|
69
|
+
fanout_status: null,
|
|
70
|
+
// Always null at insert too: a bounce is reported by a webhook minutes
|
|
71
|
+
// later, and the ack is an operator gesture after that (#783).
|
|
72
|
+
bounce_type: null,
|
|
73
|
+
bounce_subtype: null,
|
|
74
|
+
bounce_message: null,
|
|
75
|
+
bounce_ack_at: null
|
|
66
76
|
}).execute();
|
|
67
77
|
const created = await getSubmissionById(db, id);
|
|
68
78
|
if (!created) throw new Error("createSubmission: row vanished after insert");
|
|
@@ -234,7 +244,11 @@ async function backfillSubmission(db, row) {
|
|
|
234
244
|
resend_message_id: row.resendMessageId,
|
|
235
245
|
spam_score: row.spamScore ?? null,
|
|
236
246
|
spam_reason: row.spamReason ?? null,
|
|
237
|
-
fanout_status: row.fanoutStatus ?? null
|
|
247
|
+
fanout_status: row.fanoutStatus ?? null,
|
|
248
|
+
bounce_type: row.bounceType ?? null,
|
|
249
|
+
bounce_subtype: row.bounceSubType ?? null,
|
|
250
|
+
bounce_message: row.bounceMessage ?? null,
|
|
251
|
+
bounce_ack_at: row.bounceAckAt ?? null
|
|
238
252
|
}).onConflict((oc) => oc.column("id").doNothing()).execute();
|
|
239
253
|
}
|
|
240
254
|
async function rescoreSubmissionSpam(db, id, score, reason) {
|
|
@@ -249,21 +263,47 @@ async function markFilteredAsRead(db, filter) {
|
|
|
249
263
|
).executeTakeFirst();
|
|
250
264
|
return Number(res.numUpdatedRows);
|
|
251
265
|
}
|
|
252
|
-
async function markNotifyBouncedByMessageId(db, messageId) {
|
|
266
|
+
async function markNotifyBouncedByMessageId(db, messageId, detail = null) {
|
|
253
267
|
if (messageId === "") return false;
|
|
254
|
-
const res = await db.updateTable("submissions").set(
|
|
268
|
+
const res = await db.updateTable("submissions").set(
|
|
269
|
+
detail === null ? { notify_status: "bounced" } : {
|
|
270
|
+
notify_status: "bounced",
|
|
271
|
+
bounce_type: detail.type,
|
|
272
|
+
bounce_subtype: detail.subType,
|
|
273
|
+
bounce_message: detail.message
|
|
274
|
+
}
|
|
275
|
+
).where("resend_message_id", "=", messageId).executeTakeFirst();
|
|
255
276
|
if (Number(res.numUpdatedRows) > 0) return true;
|
|
256
277
|
const existing = await db.selectFrom("submissions").select("id").where("resend_message_id", "=", messageId).executeTakeFirst();
|
|
257
278
|
return existing !== void 0;
|
|
258
279
|
}
|
|
280
|
+
async function ackNotifyBounce(db, id, now) {
|
|
281
|
+
const res = await db.updateTable("submissions").set({ bounce_ack_at: now.toISOString() }).where("id", "=", id).where("notify_status", "=", "bounced").where("bounce_ack_at", "is", null).executeTakeFirst();
|
|
282
|
+
return Number(res.numUpdatedRows) > 0;
|
|
283
|
+
}
|
|
259
284
|
async function countNotifyBouncedBySite(db, sinceDate) {
|
|
260
|
-
const rows = await db.selectFrom("submissions").select([
|
|
261
|
-
|
|
285
|
+
const rows = await db.selectFrom("submissions").select([
|
|
286
|
+
"site_id",
|
|
287
|
+
(eb) => eb.fn.countAll().as("n"),
|
|
288
|
+
// lower() is ASCII-only in SQLite, which is all this needs: the value is a
|
|
289
|
+
// Resend enum ("Permanent"/"Transient"/"Undetermined"). Matching it
|
|
290
|
+
// case-insensitively here keeps the SQL agreeing with isPermanentBounce.
|
|
291
|
+
sql`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(
|
|
292
|
+
"permanent_n"
|
|
293
|
+
)
|
|
294
|
+
]).where("notify_status", "=", "bounced").where("submitted_at", ">=", sinceDate).where("bounce_ack_at", "is", null).groupBy("site_id").execute();
|
|
295
|
+
return new Map(
|
|
296
|
+
rows.map((r) => [
|
|
297
|
+
r.site_id,
|
|
298
|
+
{ total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 }
|
|
299
|
+
])
|
|
300
|
+
);
|
|
262
301
|
}
|
|
263
302
|
export {
|
|
264
303
|
MIN_DUP_BODY_LEN,
|
|
265
304
|
MIN_SIMILAR_TOKENS,
|
|
266
305
|
SIMILARITY_THRESHOLD,
|
|
306
|
+
ackNotifyBounce,
|
|
267
307
|
backfillSubmission,
|
|
268
308
|
countAutoSpamSince,
|
|
269
309
|
countNotifyBouncedBySite,
|
|
@@ -286,4 +326,4 @@ export {
|
|
|
286
326
|
stampFanout,
|
|
287
327
|
stampNotified
|
|
288
328
|
};
|
|
289
|
-
//# sourceMappingURL=submissions-
|
|
329
|
+
//# sourceMappingURL=submissions-JDZP35JM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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 type BounceDetail,\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 bounceType: r.bounce_type,\n bounceSubType: r.bounce_subtype,\n bounceMessage: r.bounce_message,\n bounceAckAt: r.bounce_ack_at,\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 // Always null at insert too: a bounce is reported by a webhook minutes\n // later, and the ack is an operator gesture after that (#783).\n bounce_type: null,\n bounce_subtype: null,\n bounce_message: null,\n bounce_ack_at: 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 bounce_type: row.bounceType ?? null,\n bounce_subtype: row.bounceSubType ?? null,\n bounce_message: row.bounceMessage ?? null,\n bounce_ack_at: row.bounceAckAt ?? 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(\n db: Db,\n messageId: string,\n /** Resend's `data.bounce`, parsed (#783). Null for a complaint, which carries\n * no bounce object, and for any payload whose shape we did not recognize.\n *\n * A null detail deliberately does NOT clear a classification already stored:\n * a svix replay of the same event, or a later complaint on a message that\n * already bounced, must not erase the diagnosis the first delivery carried. */\n detail: BounceDetail | null = null,\n): Promise<boolean> {\n if (messageId === \"\") return false;\n const res = await db\n .updateTable(\"submissions\")\n .set(\n detail === null\n ? { notify_status: \"bounced\" }\n : {\n notify_status: \"bounced\",\n bounce_type: detail.type,\n bounce_subtype: detail.subType,\n bounce_message: detail.message,\n },\n )\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/** Acknowledge ONE bounced notification as \"not a dead address\" (#783).\n *\n * The row keeps its bounce record — status, type, sub-type and the receiving\n * server's message all stay — and simply stops counting toward the alarm. That\n * is the difference from the production workaround this replaces, which flipped\n * `bounced` back to `sent` by id and destroyed the evidence along with the\n * alarm.\n *\n * Guarded on `notify_status = 'bounced'` (there is nothing to acknowledge on a\n * healthy row) and on `bounce_ack_at IS NULL`, which makes it idempotent and\n * keeps the FIRST acknowledgement's timestamp — the honest one, since that is\n * when the operator actually looked. Returns whether this call was the one that\n * wrote. A PERMANENT bounce is deliberately still ackable: the ack means \"I\n * have looked at this\", not \"this was a false alarm\", and refusing it would\n * leave no way to close the case after genuinely fixing the mailbox. */\nexport async function ackNotifyBounce(db: Db, id: string, now: Date): Promise<boolean> {\n const res = await db\n .updateTable(\"submissions\")\n .set({ bounce_ack_at: now.toISOString() })\n .where(\"id\", \"=\", id)\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"bounce_ack_at\", \"is\", null)\n .executeTakeFirst();\n return Number(res.numUpdatedRows) > 0;\n}\n\n/** Per-site bounce counts, split by whether the payload said the ADDRESS is bad. */\nexport type NotifyBounceCounts = {\n /** Unacknowledged bounced notifications in the window. */\n total: number;\n /** How many of those Resend classified `Permanent` (#783). Counted ONLY where\n * the payload actually said so: a null classification — every row written\n * before migration 0018, and every complaint — counts toward `total` and\n * never toward this. Reading null as permanent would keep accusing the\n * address on exactly the rows that caused the false alarm. */\n permanent: number;\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).\n *\n * Acknowledged rows are excluded outright (#783), which is what lets a diagnosed\n * false alarm clear the same day instead of aging out over fourteen. A site whose\n * every bounce is acked drops out of the map entirely, exactly like a site that\n * never bounced — both are \"nothing to raise\". */\nexport async function countNotifyBouncedBySite(\n db: Db,\n sinceDate: string,\n): Promise<Map<string, NotifyBounceCounts>> {\n const rows = await db\n .selectFrom(\"submissions\")\n .select([\n \"site_id\",\n (eb) => eb.fn.countAll<number>().as(\"n\"),\n // lower() is ASCII-only in SQLite, which is all this needs: the value is a\n // Resend enum (\"Permanent\"/\"Transient\"/\"Undetermined\"). Matching it\n // case-insensitively here keeps the SQL agreeing with isPermanentBounce.\n sql<number>`sum(case when lower(bounce_type) = 'permanent' then 1 else 0 end)`.as(\n \"permanent_n\",\n ),\n ])\n .where(\"notify_status\", \"=\", \"bounced\")\n .where(\"submitted_at\", \">=\", sinceDate)\n .where(\"bounce_ack_at\", \"is\", null)\n .groupBy(\"site_id\")\n .execute();\n return new Map(\n rows.map((r) => [\n r.site_id,\n { total: Number(r.n) || 0, permanent: Number(r.permanent_n) || 0 },\n ]),\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -5,20 +5,20 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-SHGIDTYF.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-H445EH33.js";
|
|
12
12
|
import {
|
|
13
13
|
svelteCodemods
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-RRE74VWI.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-SQ4FSNYL.js";
|
|
17
17
|
import "./chunk-Z3JXLULD.js";
|
|
18
18
|
import "./chunk-LBYOLBW7.js";
|
|
19
19
|
import "./chunk-RARREDQE.js";
|
|
20
20
|
import "./chunk-XXTZBPUY.js";
|
|
21
|
-
import "./chunk-
|
|
21
|
+
import "./chunk-EASYCD6F.js";
|
|
22
22
|
|
|
23
23
|
// src/cli/commands/svelte-codemods.ts
|
|
24
24
|
import { resolve } from "path";
|
|
@@ -51,4 +51,4 @@ async function runSvelteCodemodsCommand(site, opts) {
|
|
|
51
51
|
export {
|
|
52
52
|
runSvelteCodemodsCommand
|
|
53
53
|
};
|
|
54
|
-
//# sourceMappingURL=svelte-codemods-
|
|
54
|
+
//# sourceMappingURL=svelte-codemods-QWGIRNN6.js.map
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
checkFleetParity,
|
|
3
3
|
formatParityResult
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-D3CTOCUR.js";
|
|
5
|
+
import "./chunk-GEUC5VGM.js";
|
|
6
6
|
import {
|
|
7
7
|
formatReapSummary,
|
|
8
8
|
importFleetState
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-UIITN37J.js";
|
|
10
10
|
import "./chunk-F3JWOPTG.js";
|
|
11
11
|
import "./chunk-VAD43MOR.js";
|
|
12
|
-
import "./chunk-
|
|
12
|
+
import "./chunk-KN52PM63.js";
|
|
13
13
|
|
|
14
14
|
// src/db/sync.ts
|
|
15
15
|
async function syncFleetState(db, io) {
|
|
@@ -43,4 +43,4 @@ export {
|
|
|
43
43
|
formatSyncResult,
|
|
44
44
|
syncFleetState
|
|
45
45
|
};
|
|
46
|
-
//# sourceMappingURL=sync-
|
|
46
|
+
//# sourceMappingURL=sync-NKPDCMYW.js.map
|
|
@@ -5,10 +5,10 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-SHGIDTYF.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-H445EH33.js";
|
|
12
12
|
import {
|
|
13
13
|
ALL_CONFIG_NAMES,
|
|
14
14
|
CANONICAL_GITIGNORE_ENTRIES,
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
mergeGitignore,
|
|
17
17
|
planTemplateDiffs,
|
|
18
18
|
syncConfigs
|
|
19
|
-
} from "./chunk-
|
|
20
|
-
import "./chunk-
|
|
19
|
+
} from "./chunk-IMYL67JV.js";
|
|
20
|
+
import "./chunk-SQ4FSNYL.js";
|
|
21
21
|
import {
|
|
22
22
|
ALL_TEMPLATES,
|
|
23
23
|
templatesByName
|
|
@@ -26,7 +26,7 @@ import "./chunk-Z3JXLULD.js";
|
|
|
26
26
|
import "./chunk-LBYOLBW7.js";
|
|
27
27
|
import "./chunk-RARREDQE.js";
|
|
28
28
|
import "./chunk-XXTZBPUY.js";
|
|
29
|
-
import "./chunk-
|
|
29
|
+
import "./chunk-EASYCD6F.js";
|
|
30
30
|
|
|
31
31
|
// src/cli/commands/sync-configs.ts
|
|
32
32
|
import { readFile } from "fs/promises";
|
|
@@ -107,4 +107,4 @@ async function runSyncConfigsCommand(site, opts) {
|
|
|
107
107
|
export {
|
|
108
108
|
runSyncConfigsCommand
|
|
109
109
|
};
|
|
110
|
-
//# sourceMappingURL=sync-configs-
|
|
110
|
+
//# sourceMappingURL=sync-configs-HGLQV55H.js.map
|
|
@@ -5,21 +5,21 @@ import {
|
|
|
5
5
|
appendSkipNotice,
|
|
6
6
|
fleetWorkdir,
|
|
7
7
|
prepareFleetSites
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-SHGIDTYF.js";
|
|
9
9
|
import {
|
|
10
10
|
resolveSites
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-H445EH33.js";
|
|
12
12
|
import {
|
|
13
13
|
upgradeSvelte4to5
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-ECDJDHKT.js";
|
|
15
15
|
import "./chunk-J7SZQUCW.js";
|
|
16
|
-
import "./chunk-
|
|
16
|
+
import "./chunk-SQ4FSNYL.js";
|
|
17
17
|
import "./chunk-OTGB6YQO.js";
|
|
18
18
|
import "./chunk-Z3JXLULD.js";
|
|
19
19
|
import "./chunk-LBYOLBW7.js";
|
|
20
20
|
import "./chunk-RARREDQE.js";
|
|
21
21
|
import "./chunk-XXTZBPUY.js";
|
|
22
|
-
import "./chunk-
|
|
22
|
+
import "./chunk-EASYCD6F.js";
|
|
23
23
|
|
|
24
24
|
// src/cli/commands/upgrade.ts
|
|
25
25
|
import { resolve } from "path";
|
|
@@ -60,4 +60,4 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
|
|
|
60
60
|
export {
|
|
61
61
|
runUpgradeCommand
|
|
62
62
|
};
|
|
63
|
-
//# sourceMappingURL=upgrade-
|
|
63
|
+
//# sourceMappingURL=upgrade-WZ44IARG.js.map
|
package/dist/util/git.d.ts
CHANGED
|
@@ -72,20 +72,59 @@ declare function isOwnerRepo(repo: string): boolean;
|
|
|
72
72
|
* before reusing it. Returns false if either side is unparseable.
|
|
73
73
|
*/
|
|
74
74
|
declare function sameOwnerRepo(a: string, b: string): boolean;
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Derive `owner/repo` from a GITHUB git remote URL (https, ssh, git:// or
|
|
77
|
+
* scp-style). `null` when the remote is not a GitHub repository URL: another
|
|
78
|
+
* host, a traversal segment, a nested or too-short path, a local filesystem
|
|
79
|
+
* path, anything unparseable.
|
|
80
|
+
*
|
|
81
|
+
* PARSED, not pattern-matched (#712). The previous implementation stripped
|
|
82
|
+
* `^https?://[^/]+/` — throwing the HOST away — and returned the last two path
|
|
83
|
+
* segments of whatever remained, so each of these yielded a confident,
|
|
84
|
+
* correctly-shaped GitHub write target for a repository the code was not in:
|
|
85
|
+
* `https://gitlab.com/acme/site.git` -> `acme/site`;
|
|
86
|
+
* `https://user@evil.example.com/attacker/target` -> `attacker/target`;
|
|
87
|
+
* `https://github.com/ok/repo/../../evil/target` -> `evil/target`;
|
|
88
|
+
* `https://github.com/ok/repo?x=/evil/other` -> `evil/other`;
|
|
89
|
+
* `/Users/me/Documents/GitHub/reddoor-starter` -> `GitHub/reddoor-starter`.
|
|
90
|
+
*
|
|
91
|
+
* A `..` anywhere in the raw remote is REFUSED rather than resolved: `new URL`
|
|
92
|
+
* (like curl, and so git-over-https) collapses `ok/repo/../../evil/target` to
|
|
93
|
+
* `/evil/target`, while ssh hands the literal path to the server. The two
|
|
94
|
+
* transports disagree about which repository the remote names, so neither
|
|
95
|
+
* answer is proven — and `..` is already inadmissible in an identity this
|
|
96
|
+
* project accepts (see {@link isOwnerRepo}).
|
|
97
|
+
*/
|
|
76
98
|
declare function parseOwnerRepo(remoteUrl: string): string | null;
|
|
77
99
|
/** `origin` remote URL for a checkout, trimmed. Throws (via git) if there's no origin. */
|
|
78
100
|
declare function getRemoteUrl(cwd: string): Promise<string>;
|
|
79
101
|
/**
|
|
80
|
-
* Resolve the `owner/repo` a recipe will act on
|
|
81
|
-
* (Airtable, or a JSON inventory) wins;
|
|
82
|
-
* `origin`.
|
|
102
|
+
* Resolve the `owner/repo` a recipe will act on, and PROVE it rather than infer
|
|
103
|
+
* it. An explicit `site.gitRepo` (Airtable, or a JSON inventory) wins;
|
|
104
|
+
* otherwise it is derived from the checkout's `origin`.
|
|
105
|
+
*
|
|
106
|
+
* Returns `null` for the two states that genuinely mean "nothing is wired
|
|
107
|
+
* here": `site.path` is not inside a git work tree, and the work tree has no
|
|
108
|
+
* `origin`. Everything else THROWS, naming both sides, because callers write
|
|
109
|
+
* repo secrets, branch-protection rulesets and pull requests at this identity
|
|
110
|
+
* — a wrong one is not cosmetic (#712):
|
|
83
111
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
112
|
+
* - a value that is present but is not a clean `owner/repo`;
|
|
113
|
+
* - an `origin` that is not a GitHub repository URL. A GitLab or impostor host
|
|
114
|
+
* answered with a GitHub write target is the core of this class;
|
|
115
|
+
* - a `site.path` that is not the ROOT of its work tree. `git remote get-url`
|
|
116
|
+
* walks UP, so a site directory with no clone in it yet — exactly what the
|
|
117
|
+
* positional `/new-site` route points at — resolved to whatever repository
|
|
118
|
+
* happened to enclose it. Run a recipe against such a directory from under
|
|
119
|
+
* the maintenance checkout and the token secret plus a ruleset landed on
|
|
120
|
+
* `reddoorla/reddoor-maintenance`. No attacker required; a wrong `cd` was
|
|
121
|
+
* enough. The cost is that "run me from a subdirectory" now refuses instead
|
|
122
|
+
* of guessing — the refusal names the root, so the fix is one `cd`, or an
|
|
123
|
+
* explicit identity;
|
|
124
|
+
* - any git failure that is not one of the two benign shapes above. "There is
|
|
125
|
+
* no remote" and "git could not run" used to be the same answer to the
|
|
126
|
+
* caller, so `selfUpdating` printed "add an origin remote" for a missing
|
|
127
|
+
* binary, an unreadable path and a dubious-ownership refusal alike.
|
|
89
128
|
*
|
|
90
129
|
* Extracted from `self-updating`'s private `resolveRepo` because `prismic-ci`
|
|
91
130
|
* read `site.gitRepo` directly and therefore refused every POSITIONAL run with
|
package/dist/util/git.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
assetRef
|
|
3
|
-
} from "./chunk-UWJZXL2D.js";
|
|
4
1
|
import {
|
|
5
2
|
runMigration
|
|
6
3
|
} from "./chunk-6DOFNDXU.js";
|
|
4
|
+
import {
|
|
5
|
+
assetRef
|
|
6
|
+
} from "./chunk-UWJZXL2D.js";
|
|
7
7
|
|
|
8
8
|
// src/cli/commands/webflow.ts
|
|
9
9
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
@@ -545,4 +545,4 @@ async function runWebflowCommand(action, target, opts = {}) {
|
|
|
545
545
|
export {
|
|
546
546
|
runWebflowCommand
|
|
547
547
|
};
|
|
548
|
-
//# sourceMappingURL=webflow-
|
|
548
|
+
//# sourceMappingURL=webflow-624FEBFD.js.map
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SEVERITY_RANK,
|
|
7
7
|
WEBSITES_TABLE,
|
|
8
8
|
allActionableVulnsTransitive,
|
|
9
|
+
analyticsHealthFields,
|
|
9
10
|
getWebsiteBySlug,
|
|
10
11
|
isArchivedStatus,
|
|
11
12
|
isDashboardVisible,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
updateScores,
|
|
34
35
|
updateSecurityCounts,
|
|
35
36
|
updateSiteField
|
|
36
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-KN52PM63.js";
|
|
37
38
|
export {
|
|
38
39
|
ACTIVE_STATUSES,
|
|
39
40
|
ARCHIVED_STATUSES,
|
|
@@ -42,6 +43,7 @@ export {
|
|
|
42
43
|
SEVERITY_RANK,
|
|
43
44
|
WEBSITES_TABLE,
|
|
44
45
|
allActionableVulnsTransitive,
|
|
46
|
+
analyticsHealthFields,
|
|
45
47
|
getWebsiteBySlug,
|
|
46
48
|
isArchivedStatus,
|
|
47
49
|
isDashboardVisible,
|
|
@@ -70,4 +72,4 @@ export {
|
|
|
70
72
|
updateSecurityCounts,
|
|
71
73
|
updateSiteField
|
|
72
74
|
};
|
|
73
|
-
//# sourceMappingURL=websites-
|
|
75
|
+
//# sourceMappingURL=websites-6DHIHJ2D.js.map
|
|
@@ -3,15 +3,15 @@ import {
|
|
|
3
3
|
formatFleetWriteSummary,
|
|
4
4
|
writeAuditsToAirtable,
|
|
5
5
|
writeFleetAuditsToAirtable
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
8
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-ZH5LOQET.js";
|
|
7
|
+
import "./chunk-37B4LVB3.js";
|
|
8
|
+
import "./chunk-WQLNUEZL.js";
|
|
9
9
|
import "./chunk-5L3FPQIJ.js";
|
|
10
|
-
import "./chunk-
|
|
10
|
+
import "./chunk-KN52PM63.js";
|
|
11
11
|
export {
|
|
12
12
|
fleetWriteFailed,
|
|
13
13
|
formatFleetWriteSummary,
|
|
14
14
|
writeAuditsToAirtable,
|
|
15
15
|
writeFleetAuditsToAirtable
|
|
16
16
|
};
|
|
17
|
-
//# sourceMappingURL=write-audits-to-airtable-
|
|
17
|
+
//# sourceMappingURL=write-audits-to-airtable-BIK6LNVC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddoorla/maintenance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Canonical maintenance configs, audits, and recipes for the reddoor stack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -117,8 +117,9 @@
|
|
|
117
117
|
"zod": "^4.4.3"
|
|
118
118
|
},
|
|
119
119
|
"scripts": {
|
|
120
|
-
"verify": "pnpm typecheck && pnpm lint && pnpm build && pnpm test:coverage && pnpm test:dist",
|
|
120
|
+
"verify": "pnpm typecheck && pnpm lint && pnpm check:match-harness-snapshots && pnpm build && pnpm test:coverage && pnpm test:dist",
|
|
121
121
|
"build": "tsup",
|
|
122
|
+
"check:match-harness-snapshots": "node scripts/check-match-harness-snapshots.mjs",
|
|
122
123
|
"dev": "tsup --watch",
|
|
123
124
|
"pretest": "pnpm run build",
|
|
124
125
|
"test": "vitest run",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/reports/header-image/assets/index.ts","../src/reports/header-image/index.ts","../src/reports/header-image/capture.ts","../src/reports/header-image/compose.ts","../src/reports/header-image/geometry.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst PLATE = \"plate-clean.png\";\n\n/** Per-report-type headline overlays, stamped over the CLEAN plate at send time\n * (see orchestrate.ts). Types with no entry go out on the clean plate.\n *\n * Source: Figma \"Web-Maintenance-Email\" (mQ3hy2d9JnOG9ljCzbZS8j) —\n * Maintenance is node 158:15, Testing is 153:721 (\"maintenance & testing\",\n * which is what a Testing report actually covers). Export each at 4x; Figma\n * crops a text export to its ink box, which is why these are 1328x660 /\n * 1715x664 rather than the 1840x720 text box.\n *\n * ⚠️ A Figma MCP `download_assets` export arrives FLATTENED — never\n * transparent — and it flattens onto WHATEVER SITS BEHIND THE NODE, which is\n * not one fixed colour: Maintenance/Testing live inside a white Header frame\n * and come back on white, while Announcement/Launch sit on the bare canvas and\n * come back on Figma's canvas grey (30,30,30). Stamping either unprocessed\n * paints a solid slab over the paper texture — that, not a bad design file, is\n * what made the 2026-08-20 Testing asset unusable.\n *\n * Recover the alpha instead of re-exporting by hand: for ink C over backdrop B,\n * a = (px - B) / (C - B). DETECT B (the export's most common colour) rather\n * than assuming white, and pick the channel with the largest |C - B| — green\n * separates the brand red from white by 221 levels but from canvas grey by only\n * 4, which would quantise the alpha to noise. Verified against the known-good\n * Maintenance asset at mean abs alpha diff 0.022, 0.0037% of pixels off by\n * more than 8.\n *\n * Editing this copy needs Figma desktop, not the Plugin API: `characters`\n * requires loadFontAsync and the MCP font environment carries no Helvetica at\n * all, so the licensed Helvetica Neue LT Std can never load there. */\nconst HEADLINE_FILES = {\n Maintenance: \"headline-maintenance.png\",\n Testing: \"headline-testing.png\",\n Announcement: \"headline-announcement.png\",\n Launch: \"headline-launch.png\",\n} as const;\n\nexport type HeadlineKind = keyof typeof HEADLINE_FILES;\n\n/** The headline kind for a report type, or null for types that ship clean. */\nexport function headlineKindFor(reportType: string): HeadlineKind | null {\n return Object.hasOwn(HEADLINE_FILES, reportType) ? (reportType as HeadlineKind) : null;\n}\n\n// Walk up from this module's URL looking for the assets dir in either the dev\n// layout (src/reports/header-image/assets/) or the published layout\n// (dist/reports/header-image/assets/). REQUIRED because tsup inlines this module\n// into dist/cli/bin.js — so import.meta.url-based sibling resolution looks in\n// dist/cli/ and fails with ENOENT. This exact bug shipped in 0.10.0-0.10.1 for\n// the maintenance-email assets; tests passed in dev because vitest evaluates the\n// source file, where import.meta.url is already correct. Only `pnpm test:dist`\n// catches a regression here.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n for (;;) {\n for (const layout of [\"src\", \"dist\"]) {\n const candidate = join(dir, layout, \"reports\", \"header-image\", \"assets\", PLATE);\n if (existsSync(candidate)) {\n cachedAssetsDir = dirname(candidate);\n return cachedAssetsDir;\n }\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadPlate: could not locate the header-image assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/** Read the bundled 2400x3200 CLEAN plate (no headline — the report type's\n * headline is stamped at send time, so one stored image serves every type). */\nexport async function loadPlate(): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), PLATE)));\n}\n\n/** Read a headline overlay (transparent PNG, ink-box cropped). */\nexport async function loadHeadline(kind: HeadlineKind): Promise<Uint8Array> {\n return new Uint8Array(await readFile(join(resolveAssetsDir(), HEADLINE_FILES[kind])));\n}\n","import sharp from \"sharp\";\nimport { captureHomepage, type Shooter } from \"./capture.js\";\nimport { composeHeaderImage, stampHeadline, headlineInkCount } from \"./compose.js\";\nimport { loadPlate, loadHeadline, headlineKindFor } from \"./assets/index.js\";\nimport { CANVAS } from \"./geometry.js\";\n\n/** Headline-red pixels tolerated in the band before a stored header is treated\n * as already carrying a headline. A clean-plate header measures 0 and a baked\n * one ~62,000, so this sits far from both — it exists only to absorb stray\n * JPEG artefacts, not to tune a boundary. */\nconst HEADLINE_INK_TOLERANCE = 500;\n\nexport type GenerateInput = {\n /** The site's production URL. */\n url: string;\n /** Airtable site slug; drives the attachment filename. */\n slug?: string;\n /** Injected browser IO (tests). */\n shooter?: Shooter;\n /** Per-site settle override for animation-heavy or consent-gated homepages. */\n settleMs?: number;\n};\n\nexport type GeneratedHeaderImage = {\n bytes: Uint8Array;\n /** The domain as printed on the plate. */\n domain: string;\n filename: string;\n contentType: \"image/jpeg\";\n};\n\n/** Strip scheme, leading www and any path — the plate prints a bare domain. */\nexport function domainFromUrl(url: string): string {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n}\n\n/**\n * A capture is \"blank\" when it is almost entirely near-white — a consent gate,\n * a failed render (blank canvas), or a shot fired before first paint. The\n * operator reviews the rendered email before sending, so this is a backstop\n * rather than the only gate; its job is to stop an obviously broken shot from\n * replacing a good stored header.\n *\n * Uniformity alone is NOT the signal: a homepage with a large solid-colour\n * hero (any brand colour) is legitimately uniform but not blank. Only\n * near-white uniformity is flagged, since every real failure mode here\n * (consent overlay, unrendered canvas, pre-paint flash) lands on a white\n * background.\n */\nasync function assertNotBlank(shot: Uint8Array): Promise<void> {\n const { data, info } = await sharp(Buffer.from(shot))\n .resize(64, 40, { fit: \"fill\" })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n const total = info.width * info.height;\n const at = (i: number): number => data[i] ?? 0;\n\n let sumR = 0;\n let sumG = 0;\n let sumB = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n sumR += at(i);\n sumG += at(i + 1);\n sumB += at(i + 2);\n }\n const meanR = sumR / total;\n const meanG = sumG / total;\n const meanB = sumB / total;\n\n let near = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n if (\n Math.abs(at(i) - meanR) < 8 &&\n Math.abs(at(i + 1) - meanG) < 8 &&\n Math.abs(at(i + 2) - meanB) < 8\n ) {\n near++;\n }\n }\n\n const uniform = near / total > 0.95;\n const nearWhite = meanR > 240 && meanG > 240 && meanB > 240;\n if (uniform && nearWhite) {\n throw new Error(\n \"header-image: capture looks blank (near-white, >95% one colour) — keeping the existing header\",\n );\n }\n}\n\n/**\n * Stamp the report type's headline onto a stored (clean) header at send time.\n * Types with no headline registered pass through untouched, as does any header\n * that must not be stamped:\n *\n * - wrong dimensions (a legacy hand-made header), where fixed coordinates\n * would misplace the text;\n * - a header that ALREADY CARRIES A HEADLINE, i.e. one built on the old baked\n * plate. Stamping those prints the new headline directly over the baked one\n * and the two overprint into unreadable pulp — which shipped in a real\n * announcement on 2026-08-24. An earlier version of this comment claimed\n * such headers were safe because they'd \"keep their baked headline\"; that\n * was wrong. They are canvas-sized, so nothing stopped them being stamped.\n *\n * The band is empty on a clean-plate header and holds ~62k red px on a baked\n * one, so the check is unambiguous rather than a tuned threshold. A skipped\n * header still goes out looking correct (its baked headline is a real headline)\n * and self-heals on the site's next draft, which regenerates it clean;\n * `header-image --all --force` does the whole fleet at once.\n */\nexport async function applyReportTypeHeadline(\n header: Uint8Array,\n reportType: string,\n): Promise<Uint8Array> {\n const kind = headlineKindFor(reportType);\n if (!kind) return header;\n // Best-effort by design: the headline is cosmetic, so anything unexpected —\n // undecodable bytes included — warns and sends the header as stored rather\n // than failing the send. Mandatory processing (and its hard failures) lives\n // in prepareHeaderImage downstream.\n try {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n console.warn(\n `⚑ header headline skipped: stored header is ${meta.width}x${meta.height}, not ${CANVAS.width}x${CANVAS.height} — sending it as stored`,\n );\n return header;\n }\n const existing = await headlineInkCount(header);\n if (existing > HEADLINE_INK_TOLERANCE) {\n console.warn(\n `⚑ header headline skipped: the stored header already carries a headline (${existing} ink px in the band) — it predates the clean-plate switch, so stamping would overprint. Sending it as stored; run \\`header-image ${\"<site>\"} --write-airtable\\` to regenerate it clean.`,\n );\n return header;\n }\n return await stampHeadline(header, await loadHeadline(kind));\n } catch (err) {\n console.warn(\n `⚑ header headline skipped (${err instanceof Error ? err.message : String(err)}) — sending the header as stored`,\n );\n return header;\n }\n}\n\n/** Capture a site's homepage and composite its header image. */\nexport async function generateHeaderImage(input: GenerateInput): Promise<GeneratedHeaderImage> {\n const screenshot = await captureHomepage(input.url, {\n ...(input.shooter !== undefined ? { shooter: input.shooter } : {}),\n ...(input.settleMs !== undefined ? { settleMs: input.settleMs } : {}),\n });\n await assertNotBlank(screenshot);\n\n const domain = domainFromUrl(input.url);\n const bytes = await composeHeaderImage({\n plate: await loadPlate(),\n screenshot,\n domain,\n });\n const base = input.slug ?? domain.split(\".\")[0];\n return { bytes, domain, filename: `${base}Header.jpg`, contentType: \"image/jpeg\" };\n}\n","/** Viewport matching the plate's MacBook screen aspect (16:10), so the crop in\n * compose.ts is a no-op for a well-behaved homepage. */\nconst VIEWPORT = { width: 1600, height: 1000 } as const;\n/** 2x so the 1349px-wide screen rect is fed real pixels, not upscaled ones. */\nconst DEVICE_SCALE_FACTOR = 2;\n/** Entrance animations and webfonts settle before the shutter. Measured against\n * live fleet sites; overridable per site because consent gates and long\n * animations vary. */\nconst DEFAULT_SETTLE_MS = 2500;\n/** Budget for the `load` milestone — the navigation we actually gate on.\n *\n * Do NOT put `waitUntil: \"networkidle\"` back on the `goto`. Measured across all\n * 14 live fleet sites (30s timeout): `load` succeeded on 14/14 in 346–1205ms,\n * while `networkidle` NEVER fired on 4 of them (ERP Industrials, Vineyard,\n * Revogen, 1836dig) — chat widgets, analytics polling and websockets hold the\n * connection open forever, so `goto` threw and those sites could never get a\n * header image at all. */\nconst NAV_TIMEOUT_MS = 60_000;\n/** Best-effort extra wait for network idle, applied *after* `load` and with its\n * rejection swallowed. The 10 sites that do settle still get the benefit; the 4\n * that never will just proceed. 3s is sized from the measurement above: the\n * slowest site that does reach idle took 4078ms from `goto` start, and by the\n * time this runs the `load` milestone (~0.3–1.2s) has already passed, so 3s\n * covers the real remaining settle time without stalling the never-idle sites. */\nconst IDLE_BUDGET_MS = 3_000;\n\nexport type ShootOptions = {\n url: string;\n width: number;\n height: number;\n deviceScaleFactor: number;\n settleMs: number;\n};\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type Shooter = {\n shoot: (opts: ShootOptions) => Promise<Uint8Array>;\n};\n\nexport type CaptureOptions = {\n shooter?: Shooter;\n settleMs?: number;\n};\n\n/**\n * Screenshot a site's homepage for the header image.\n *\n * Viewport-only, never `fullPage`: the plate shows a laptop screen, not a\n * scroll. Failures propagate — a caller must be able to keep the site's existing\n * header rather than overwrite it with a broken shot.\n */\nexport async function captureHomepage(\n url: string,\n options: CaptureOptions = {},\n): Promise<Uint8Array> {\n const shooter = options.shooter ?? (await defaultShooter());\n return shooter.shoot({\n url,\n width: VIEWPORT.width,\n height: VIEWPORT.height,\n deviceScaleFactor: DEVICE_SCALE_FACTOR,\n settleMs: options.settleMs ?? DEFAULT_SETTLE_MS,\n });\n}\n\n/** Real Playwright shooter. Lazily imported so unit tests never load it and the\n * static import graph stays central-dep-free for `test:dist`. */\nexport async function defaultShooter(): Promise<Shooter> {\n const { chromium } = await import(\"@playwright/test\");\n return {\n async shoot(opts) {\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({\n viewport: { width: opts.width, height: opts.height },\n deviceScaleFactor: opts.deviceScaleFactor,\n });\n await page.goto(opts.url, { waitUntil: \"load\", timeout: NAV_TIMEOUT_MS });\n // Best-effort only — a site that never idles must still be captured.\n await page.waitForLoadState(\"networkidle\", { timeout: IDLE_BUDGET_MS }).catch(() => {});\n await page.evaluate(\"document.fonts && document.fonts.ready\");\n await page.waitForTimeout(opts.settleMs);\n const buf = await page.screenshot({ type: \"png\" });\n return new Uint8Array(buf);\n } finally {\n await browser.close();\n }\n },\n };\n}\n","import sharp from \"sharp\";\nimport type { OverlayOptions } from \"sharp\";\nimport {\n CANVAS,\n SCREEN,\n DOMAIN,\n PAPER,\n HEADLINE,\n HEADLINE_BAND,\n HEADLINE_INK,\n} from \"./geometry.js\";\n\nexport type ComposeInput = {\n /** The bundled 2400x3200 plate. */\n plate: Uint8Array;\n /** A homepage screenshot, any size. Cropped to fill the screen rect. */\n screenshot: Uint8Array;\n /** The site's public domain, drawn bottom-left (e.g. \"gallerysonder.com\"). */\n domain: string;\n};\n\n/** JPEG quality. The hand-made headers are ~2.4MB; 88 lands near 0.7MB with no\n * visible difference at the 600px the email actually renders. */\nconst JPEG_QUALITY = 88;\n\n/** Escape the five XML metacharacters. The domain is interpolated into an SVG,\n * and a bare `&` yields invalid XML that sharp rejects outright. */\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * The domain line as a transparent SVG overlay the size of the full canvas, so\n * it can be composited at 0,0 and positioned by its own coordinates.\n *\n * ⚠️ FONT AVAILABILITY IS ENVIRONMENT-DEPENDENT. sharp renders SVG through\n * librsvg + fontconfig, so the family below resolves against INSTALLED fonts.\n * On macOS it resolves to real Helvetica Neue, which reproduces the hand-made\n * reference exactly (verified: ink box dx=0 dy=0 dw=0 dh=0). A stock\n * ubuntu-latest runner has none of Helvetica Neue / Helvetica / Arial, so it\n * falls through to `sans-serif` — typically DejaVu Sans, which is wider and\n * would render a visibly different domain line.\n *\n * This matters because report drafting (which regenerates the header) runs on\n * ubuntu-latest in `.github/workflows/daily-reports.yml`. That workflow now\n * installs `fonts-urw-base35`, which provides Nimbus Sans — metrically\n * equivalent to Helvetica and third in the stack below — so a CI-generated\n * header's domain line matches a locally-generated one. Any OTHER environment\n * that runs this code still needs a Helvetica-metric font installed.\n */\nfunction domainSvg(domain: string): Buffer {\n return Buffer.from(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${CANVAS.width}\" height=\"${CANVAS.height}\">\n <text x=\"${DOMAIN.x}\" y=\"${DOMAIN.baseline}\"\n font-family=\"Helvetica Neue, Helvetica, Nimbus Sans, Arial, sans-serif\"\n font-size=\"${DOMAIN.size}\" font-weight=\"${DOMAIN.weight}\"\n fill=\"${DOMAIN.color}\">${escapeXml(domain)}</text>\n </svg>`,\n \"utf-8\",\n );\n}\n\n/**\n * Composite one site's header image: paste its homepage screenshot into the\n * plate's laptop screen and draw its domain underneath.\n *\n * PURE — no browser, no network, no filesystem. Fidelity lives here, so it must\n * be verifiable in a unit test.\n *\n * The screenshot is cropped to FILL the 16:10 screen from the top: a taller\n * homepage loses its fold rather than being squashed, which would be obvious\n * and wrong. `flatten` guards against a transparent source punching a hole in\n * the plate.\n */\nexport async function composeHeaderImage(input: ComposeInput): Promise<Uint8Array> {\n const screen = await sharp(Buffer.from(input.screenshot))\n .resize(SCREEN.w, SCREEN.h, { fit: \"cover\", position: \"top\" })\n .flatten({ background: PAPER })\n .toBuffer();\n\n const layers: OverlayOptions[] = [{ input: screen, left: SCREEN.x, top: SCREEN.y }];\n if (input.domain.trim().length > 0) {\n layers.push({ input: domainSvg(input.domain), left: 0, top: 0 });\n }\n\n const out = await sharp(Buffer.from(input.plate))\n .composite(layers)\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n\n/**\n * Count headline-red pixels already present in the headline band.\n *\n * A header composed on the clean plate has EXACTLY ZERO (measured), because the\n * band sits between the logo and the laptop and nothing else paints there. A\n * header built on the old baked plate has ~62,000 — its headline is part of the\n * image. That gap is what lets {@link stampHeadline}'s caller refuse to print a\n * second headline over the first. PURE.\n */\nexport async function headlineInkCount(header: Uint8Array): Promise<number> {\n const { data, info } = await sharp(Buffer.from(header))\n .extract({\n left: HEADLINE_BAND.x,\n top: HEADLINE_BAND.y,\n width: HEADLINE_BAND.w,\n height: HEADLINE_BAND.h,\n })\n .removeAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n let n = 0;\n for (let i = 0; i < data.length; i += info.channels) {\n const r = data[i] ?? 0;\n const g = data[i + 1] ?? 0;\n const b = data[i + 2] ?? 0;\n // Generous band around the brand red rather than an exact match: the stored\n // header is a JPEG, so its ink has been through lossy re-encoding.\n if (r > HEADLINE_INK.r - 60 && g < HEADLINE_INK.g + 60 && b < HEADLINE_INK.b + 60) n++;\n }\n return n;\n}\n\n/**\n * Stamp a report-type headline overlay onto a finished (clean-plate) header at\n * the measured HEADLINE origin. PURE and strict: the header must be exactly\n * CANVAS-sized — a legacy hand-made header at other dimensions would misplace\n * the text, so the caller (applyReportTypeHeadline) size-guards and skips\n * instead of calling in. One extra JPEG generation at quality 88 is invisible\n * at the email's 600px display size.\n */\nexport async function stampHeadline(header: Uint8Array, headline: Uint8Array): Promise<Uint8Array> {\n const meta = await sharp(Buffer.from(header)).metadata();\n if (meta.width !== CANVAS.width || meta.height !== CANVAS.height) {\n throw new Error(\n `stampHeadline: header is ${meta.width}x${meta.height}, expected ${CANVAS.width}x${CANVAS.height}`,\n );\n }\n const out = await sharp(Buffer.from(header))\n .composite([{ input: Buffer.from(headline), left: HEADLINE.x, top: HEADLINE.y }])\n .jpeg({ quality: JPEG_QUALITY })\n .toBuffer();\n return new Uint8Array(out);\n}\n","// Measured off the real plate, not guessed.\n//\n// SCREEN is the hole INSIDE the laptop bezel. It is derived from the bezel —\n// a perfectly flat black frame (luminance 0.0) — because that is\n// content-independent. Deriving it from content instead (diffing two headers for\n// pixels that vary) yields x=282 y=1856 w=1394 h=871, which is WRONG: that rect\n// spans the laptop's outer panel, covering the bezel on three sides while\n// falling ~29px short at the bottom, so the plate's baked-in screenshot shows\n// as a strip under every site's content.\n//\n// Cross-checked: the photo-to-flat-black transition at the bottom of the plate\n// lands at y=2756, exactly SCREEN.y + SCREEN.h - 1.\n//\n// Re-measure only if the Figma template changes — and if you do, find the bezel.\n\n/** The plate's pixel dimensions — Figma frame 600x800 exported at 4x. */\nexport const CANVAS = { width: 2400, height: 3200 } as const;\n\n/** The MacBook Pro screen the site screenshot is pasted into — the area inside\n * the bezel, so the bezel stays visible around the content exactly as it does\n * in the hand-made headers. Aspect 1.5983 (16:10 to within 0.1%). */\nexport const SCREEN = { x: 302, y: 1913, w: 1349, h: 844 } as const;\n\n/** Where a report-type headline overlay lands on the clean plate.\n *\n * Measured, not guessed: red ink present in the baked maintenance plate but\n * absent from plate-clean spans x=277 y=998 w=1326 h=658 — and\n * headline-maintenance.png is that ink box cropped to its own edges\n * (1328x660, ±1px of anti-aliasing at the threshold), so compositing the\n * asset at this origin reproduces the baked plate's headline exactly. */\nexport const HEADLINE = { x: 277, y: 998 } as const;\n\n/** The band a headline occupies, sized to the WIDEST registered overlay\n * (Testing, 1715x664). Used to detect a header that already carries a baked-in\n * headline, so it can't be stamped a second time. Deliberately clear of the\n * logo above (ends y≈506) and the laptop screen below (starts y=1913), so the\n * only ink that can land here is a headline. */\nexport const HEADLINE_BAND = { x: HEADLINE.x, y: HEADLINE.y, w: 1715, h: 664 } as const;\n\n/** The headline ink colour (brand red), and how far a pixel may drift from it\n * and still count — JPEG re-encoding moves it by a level or two. */\nexport const HEADLINE_INK = { r: 209, g: 34, b: 62 } as const;\n\n/** The per-site domain line, bottom left.\n *\n * Solved against the hand-made reference rather than guessed: rendering\n * \"gallerysonder.com\" with these values reproduces Sonder's original ink box\n * (x=283 y=2963 w=645 h=74) EXACTLY — dx=0 dy=0 dw=0 dh=0. A sweep over sizes\n * 62-88 at weights 300/400 has a single exact solution at size 80 / weight 400.\n *\n * `baseline` is the text baseline in canvas pixels; the ink box sits above it\n * by the cap height and below by the descender. */\nexport const DOMAIN = {\n x: 280,\n baseline: 3020,\n size: 80,\n weight: 400,\n color: \"#747474\",\n} as const;\n\n/** Flat paper tone, used as the compositing background so a transparent or\n * short source can never punch a hole in the plate. */\nexport const PAPER = \"#fcfcfc\" as const;\n"],"mappings":"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","names":["sharp","sharp"]}
|