@rubytech/create-maxy-code 0.1.307 → 0.1.309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/claude-ptys-slice.test.js +1 -1
- package/dist/__tests__/cloudflared-slice.test.js +1 -1
- package/dist/__tests__/macos-darwin-branch.test.js +4 -6
- package/package.json +1 -1
- package/payload/platform/lib/admin-access-password/__tests__/index.test.ts +142 -0
- package/payload/platform/lib/admin-access-password/dist/index.d.ts +49 -0
- package/payload/platform/lib/admin-access-password/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/admin-access-password/dist/index.js +198 -0
- package/payload/platform/lib/admin-access-password/dist/index.js.map +1 -0
- package/payload/platform/lib/admin-access-password/src/index.ts +176 -0
- package/payload/platform/lib/admin-access-password/tsconfig.json +8 -0
- package/payload/platform/lib/models/dist/index.d.ts +13 -0
- package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/models/dist/index.js +20 -1
- package/payload/platform/lib/models/dist/index.js.map +1 -1
- package/payload/platform/lib/models/src/index.ts +19 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/mcp/dist/index.js +63 -19
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +8 -2
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +23 -3
- package/payload/platform/plugins/docs/references/admin-ui.md +21 -1
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/scripts/check-no-esm-require.mjs +4 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +4 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +193 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +12 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +52 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +9 -3
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts +8 -0
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +7 -0
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/instructions.d.ts +8 -0
- package/payload/platform/services/webchat-channel/dist/instructions.d.ts.map +1 -0
- package/payload/platform/services/webchat-channel/dist/instructions.js +44 -0
- package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -0
- package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/server.js +11 -18
- package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
- package/payload/server/{chunk-4QFPGAUZ.js → chunk-J6WDAWFJ.js} +190 -82
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/AccessGate-CU4Qw6M6.js +1 -0
- package/payload/server/public/assets/AdminLoginScreens-DUpRIM1h.js +1 -0
- package/payload/server/public/assets/AdminShell-DkoQSsqo.js +1 -0
- package/payload/server/public/assets/{Checkbox-DoPOlZTV.js → Checkbox-DMA1hVhn.js} +1 -1
- package/payload/server/public/assets/OperatorConversations-BIHiyyxU.js +1 -0
- package/payload/server/public/assets/admin-BHvOMcCh.js +1 -0
- package/payload/server/public/assets/{arc-DxSm8tli.js → arc-Dl9QFDgW.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Crq5RSDG.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-7JSAh0vL.js → architectureDiagram-Q4EWVU46-CNLQUVi9.js} +1 -1
- package/payload/server/public/assets/{audio-attachment-mime-BA10IzqQ.js → audio-attachment-mime-DOBET-W1.js} +3 -3
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DLoVmHKD.js → blockDiagram-DXYQGD6D-B-QxfVjk.js} +1 -1
- package/payload/server/public/assets/brand-4F9ZXBF8.css +1 -0
- package/payload/server/public/assets/{brand-BaRMO3mZ.js → brand-C7kj0USi.js} +1 -1
- package/payload/server/public/assets/browser-eLSiC7_b.js +1 -0
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Nv1G0o9P.js → c4Diagram-AHTNJAMY-NuYFvPDU.js} +1 -1
- package/payload/server/public/assets/channel-KT66MFN2.js +1 -0
- package/payload/server/public/assets/chat-DedpVKUl.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-BpqPMhoA.js → chunk-2KRD3SAO-l5VL1KQm.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-DgXHlVU_.js → chunk-336JU56O-C5gVznC2.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-CWMiOm8Y.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-nwyZSZH8.js → chunk-4BX2VUAB-BtMVz_Sq.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-Tfxz9LHX.js → chunk-4TB4RGXK-C8-dmCuT.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-6FMJQGXG.js → chunk-55IACEB6-C5gWvEJW.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-g2FgZF3D.js → chunk-5FUZZQ4R-BZ7VZoAC.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DtLwXsPH.js → chunk-5PVQY5BW-C_ajQOGC.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-K91CP5ZU.js → chunk-67CJDMHE-BJOR2dwK.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-BGpCQhqK.js → chunk-7N4EOEYR-DvZLpquX.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-jkDbInck.js → chunk-AA7GKIK3-h5tVsPlW.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-mDosdzGs.js → chunk-BSJP7CBP-DLOF2-m3.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-DhBxewpQ.js → chunk-CIAEETIT-CQRQMPCf.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-B7c-9Emg.js → chunk-EDXVE4YY-K9-EfuM2.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-Cqhhncox.js → chunk-ENJZ2VHE-DVoy6NvU.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-BpDq6wj1.js → chunk-FMBD7UC4-CQn_2o2b.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-Ds02-ktu.js → chunk-FOC6F5B3-CuN73goS.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-BTX5POFr.js → chunk-ICPOFSXX-CKhl9J-H.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-Du1PXXBU.js → chunk-K5T4RW27-CBskuQmq.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-siasOAf8.js → chunk-KGLVRYIC-Chtjt8xK.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-xf1rbZtf.js → chunk-LIHQZDEY-EOBiBNSH.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-DeTLQ_LD.js → chunk-ORNJ4GCN-uPPq-5MN.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-DQ7_oVJn.js → chunk-OYMX7WX6-BRzbxJOT.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-DCzpF46A.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-CXmFUKgn.js → chunk-U2HBQHQK-Bndyn5nF.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-Bj8-8Wkz.js → chunk-X2U36JSP-BspGBHXW.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-Con3TXqt.js → chunk-XPW4576I-HgXQ2k9h.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-Dx8BHGWf.js → chunk-YZCP3GAM-C7oTyCnw.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Dp4Q5Y-f.js → chunk-ZZ45TVLE-CufWa7QL.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-QOU4HzJJ.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DcOUai3Z.js +1 -0
- package/payload/server/public/assets/clone-CHh05KN-.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-pZiCv69E.js → cose-bilkent-S5V4N54A-BM3yEbF-.js} +1 -1
- package/payload/server/public/assets/{dagre-CLqhEgbL.js → dagre-C7WHNZO8.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-BCxE8iyk.js → dagre-KV5264BT-bcZx3l06.js} +1 -1
- package/payload/server/public/assets/data-BcoBY39U.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-slUEdZzz.js → diagram-5BDNPKRD-B7VotaMF.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DPcraMfu.js → diagram-G4DWMVQ6-CWWvEsFx.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-DR6luhGK.js → diagram-MMDJMWI5-DYHVkTYJ.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-Jap1B4wA.js → diagram-TYMM5635-CHeB6Ama.js} +1 -1
- package/payload/server/public/assets/{dist-Dbh7HrZX.js → dist-CjjtmoWL.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DJKUs2hO.js → erDiagram-SMLLAGMA-C9gog8QP.js} +1 -1
- package/payload/server/public/assets/{flatten-Cl1Bc3dR.js → flatten-QcJDm7yK.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-C8W-ZZ9W.js → flowDiagram-DWJPFMVM-B2O9lKTh.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DYCX4Xu2.js → ganttDiagram-T4ZO3ILL-DEa4AwbR.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-BC-u4jgy.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CZu_fpgS.js → gitGraphDiagram-UUTBAWPF-BjXPQpBL.js} +1 -1
- package/payload/server/public/assets/{graph-D29p1FJH.js → graph-C7Hye_vp.js} +2 -2
- package/payload/server/public/assets/{graph-labels-DF3QVHkH.js → graph-labels-YYgGsEVd.js} +1 -1
- package/payload/server/public/assets/{graphlib-Bdp7TA4y.js → graphlib-CBevn2DP.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-DPUetTz-.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-q2dl09-M.js +2 -0
- package/payload/server/public/assets/{isEmpty-D1pGOD1J.js → isEmpty-BO6BAEn7.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-B7KfIX6z.js → ishikawaDiagram-UXIWVN3A-CPLfpvqv.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CSDwBfhF.js → journeyDiagram-VCZTEJTY-CTHe8MiD.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CWzcEKTr.js → kanban-definition-6JOO6SKY-Bm9h1O3Y.js} +1 -1
- package/payload/server/public/assets/{line-BNQSlbYn.js → line-C31b0rtY.js} +1 -1
- package/payload/server/public/assets/{linear-BN6kGN93.js → linear-Cfm_8_pU.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-CcFhkElq.js → mermaid-parser.core-B3tGwOcE.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-BsIeJ7Cc.js → mermaid.core-BM3s85ZW.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CQVzj6D_.js → mindmap-definition-QFDTVHPH-CaGv_mIB.js} +1 -1
- package/payload/server/public/assets/operator-WsXhcbBF.js +1 -0
- package/payload/server/public/assets/{ordinal-CBZeH4Yp.js → ordinal-jLCwuvYg.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-BcfR-ZwA.js +1 -0
- package/payload/server/public/assets/page-BhHTnqv4.js +1 -0
- package/payload/server/public/assets/pie-ZZUOXDRM-BiKgZdNB.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-DnrQ9fyn.js → pieDiagram-DEJITSTG-BhAXps4u.js} +1 -1
- package/payload/server/public/assets/public-ViXjC_dA.js +6 -0
- package/payload/server/public/assets/public-next-BKECyMz2.js +1 -0
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BQWlCyWi.js → quadrantDiagram-34T5L4WZ-BWtTdNs8.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-nvCjiW_J.js +1 -0
- package/payload/server/public/assets/{reduce-PuPlFPRX.js → reduce-Dmi_NdVH.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-Du_vZhU1.js → requirementDiagram-MS252O5E-CWU0qCcP.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-Cf6Z8GPQ.js → sankeyDiagram-XADWPNL6-DYvutQld.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-CS8GVol8.js → sequenceDiagram-FGHM5R23-Cir0hBWS.js} +1 -1
- package/payload/server/public/assets/{src-BoaldmnP.js → src-CwB968DO.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-Bv1NW2F1.js → stateDiagram-FHFEXIEX-Be-h8YSS.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CAnmjSj7.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-ClGYAsr0.js → timeline-definition-GMOUNBTQ-C3C4Wuyk.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-BinXCVyM.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-CSmh20Ze.js +1 -0
- package/payload/server/public/assets/{useSelectionMode-ghXuZicl.js → useSelectionMode-CG_Iel5F.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-C2wTcxQT.js → vennDiagram-DHZGUBPP-mDclw0Sa.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-BiCmRKWx.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-D86-ivEx.js → wardleyDiagram-NUSXRM2D-mvk-YA9g.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-Ba5WAN8P.js → xychartDiagram-5P7HB3ND-9M7k1hmD.js} +1 -1
- package/payload/server/public/browser.html +8 -8
- package/payload/server/public/chat.html +11 -11
- package/payload/server/public/data.html +7 -7
- package/payload/server/public/graph.html +10 -10
- package/payload/server/public/index.html +10 -10
- package/payload/server/public/operator.html +13 -13
- package/payload/server/public/public-next.html +24 -0
- package/payload/server/public/public.html +9 -8
- package/payload/server/server.js +4450 -3781
- package/dist/__tests__/onboarding-state-readback.test.js +0 -61
- package/dist/__tests__/rss-sampler-installer.test.js +0 -27
- package/dist/onboarding-readback.js +0 -27
- package/payload/server/public/assets/AdminLoginScreens-BsC-EwAn.js +0 -1
- package/payload/server/public/assets/AdminShell-CiKKN22G.js +0 -1
- package/payload/server/public/assets/admin-DSwmdCKp.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-6g9jRVgc.js +0 -1
- package/payload/server/public/assets/brand-C001cin3.css +0 -1
- package/payload/server/public/assets/browser-BKR1eJJb.js +0 -1
- package/payload/server/public/assets/channel-BgQLJanG.js +0 -1
- package/payload/server/public/assets/chat-DZXNRiy-.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-Dr_XVuUq.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-DF4o9HRJ.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DAbPKU6u.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BKuFQLcv.js +0 -1
- package/payload/server/public/assets/clone-DnNHGhNe.js +0 -1
- package/payload/server/public/assets/data-CuVmW1WN.js +0 -1
- package/payload/server/public/assets/file-download-4_hQmMwU.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-g7mvu6IY.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-DZqJuIkB.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-cGfGccHz.js +0 -2
- package/payload/server/public/assets/operator-CQRaBNN7.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-Hn8rlHpy.js +0 -1
- package/payload/server/public/assets/page-Dz2F3QtB.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BPGWVqBm.js +0 -1
- package/payload/server/public/assets/public-B7BQwWpP.js +0 -6
- package/payload/server/public/assets/radar-PYXPWWZC-DMdLrj3E.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BGUCxPmp.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-CWxofUbI.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-Cfgb_ZG0.js +0 -1
- package/payload/server/public/assets/wardley-RL74JXVD-B45olYjj.js +0 -1
- /package/payload/server/public/assets/{file-download-qc_xy7Az.css → OperatorConversations-qc_xy7Az.css} +0 -0
- /package/payload/server/public/assets/{_baseFor-brRjpUOX.js → _baseFor-D8P3gUw3.js} +0 -0
- /package/payload/server/public/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
- /package/payload/server/public/assets/{chunk-Pqm5yXtL.js → chunk-CAM3fms7.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-TvRJ2tGX.js → cytoscape.esm-mZ4wTuB7.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
- /package/payload/server/public/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
- /package/payload/server/public/assets/{katex-RxgSu_dy.js → katex-DaHGEgm7.js} +0 -0
- /package/payload/server/public/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-CQ36nxok.js → preload-helper-TKOBYGYD.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-6CnTHTkH.js → rough.esm-JX0wREDd.js} +0 -0
|
@@ -10,7 +10,7 @@ import { buildClaudePtysSliceUnitFile } from "../port-resolution.js";
|
|
|
10
10
|
test("buildClaudePtysSliceUnitFile emits a valid systemd slice unit", () => {
|
|
11
11
|
const unit = buildClaudePtysSliceUnitFile();
|
|
12
12
|
assert.match(unit, /^\[Unit\]$/m);
|
|
13
|
-
assert.match(unit, /^Description
|
|
13
|
+
assert.match(unit, /^Description=.+$/m);
|
|
14
14
|
assert.match(unit, /^\[Slice\]$/m);
|
|
15
15
|
assert.match(unit, /^\[Install\]$/m);
|
|
16
16
|
assert.match(unit, /^WantedBy=default\.target$/m);
|
|
@@ -8,7 +8,7 @@ import { buildCloudflaredSliceUnitFile } from "../port-resolution.js";
|
|
|
8
8
|
test("buildCloudflaredSliceUnitFile emits a valid systemd slice unit", () => {
|
|
9
9
|
const unit = buildCloudflaredSliceUnitFile();
|
|
10
10
|
assert.match(unit, /^\[Unit\]$/m);
|
|
11
|
-
assert.match(unit, /^Description
|
|
11
|
+
assert.match(unit, /^Description=.+$/m);
|
|
12
12
|
assert.match(unit, /^\[Slice\]$/m);
|
|
13
13
|
assert.match(unit, /^\[Install\]$/m);
|
|
14
14
|
assert.match(unit, /^WantedBy=default\.target$/m);
|
|
@@ -31,10 +31,10 @@ test("installSystemDeps has darwin branch that calls setMacosHostnameViaScutil o
|
|
|
31
31
|
assert.match(SRC, /darwin-hostname-mode=brand-fallback/, "brand-fallback path must emit observability log");
|
|
32
32
|
});
|
|
33
33
|
test("installSystemDeps darwin branch installs DARWIN_BASE_DEPS via brew", () => {
|
|
34
|
-
// The
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// The darwin path feeds the shared dep set to installAllBrewPackages. The
|
|
35
|
+
// exact membership of DARWIN_BASE_DEPS is asserted behaviourally in
|
|
36
|
+
// brew-resolve.test.ts (DARWIN_BASE_DEPS_FIXTURE); here we only pin that the
|
|
37
|
+
// branch still routes that set through brew.
|
|
38
38
|
assert.match(SRC, /installAllBrewPackages\(DARWIN_BASE_DEPS,\s*logFile\)/);
|
|
39
39
|
});
|
|
40
40
|
test("installCloudflared darwin branch logs the documented skip and brew-installs cloudflared", () => {
|
|
@@ -58,8 +58,6 @@ test("installService darwin branch delegates to installServiceDarwin (launchd pa
|
|
|
58
58
|
// and installServiceDarwin writes the plist + bootstraps it via launchctl.
|
|
59
59
|
assert.match(SRC, /platform\s*===\s*"darwin"\s*\)\s*\{\s*installServiceDarwin\(\)/);
|
|
60
60
|
assert.match(SRC, /launchctl["']?,\s*\[["']bootstrap["']/);
|
|
61
|
-
assert.match(SRC, /launchd-plist=\$\{path\}\s+loaded=true/, "successful bootstrap must emit launchd-plist=… loaded=true");
|
|
62
|
-
assert.match(SRC, /launchd-plist=\$\{path\}\s+loaded=false/, "failed bootstrap must emit launchd-plist=… loaded=false");
|
|
63
61
|
});
|
|
64
62
|
test("installNodejs darwin branch brew-installs the `nodejs` alias name (resolver hops to node@22)", () => {
|
|
65
63
|
// The shared call site uses the apt-style `nodejs` name. brew-resolve.ts
|
package/package.json
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
hashAccessPassword,
|
|
7
|
+
setAccessPassword,
|
|
8
|
+
clearAccessPassword,
|
|
9
|
+
verifyAccessPassword,
|
|
10
|
+
anyAccessPasswordConfigured,
|
|
11
|
+
accessStoreUnreadable,
|
|
12
|
+
accessPasswordCollides,
|
|
13
|
+
migrateLegacyRemotePassword,
|
|
14
|
+
} from '../src/index'
|
|
15
|
+
|
|
16
|
+
function tmpUsers(content: unknown): string {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), 'aap-'))
|
|
18
|
+
const f = join(dir, 'users.json')
|
|
19
|
+
writeFileSync(f, JSON.stringify(content, null, 2))
|
|
20
|
+
return f
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('admin-access-password', () => {
|
|
24
|
+
it('sets accessHash on the target record only, preserving siblings', async () => {
|
|
25
|
+
const f = tmpUsers([
|
|
26
|
+
{ userId: 'a', pin: 'pinA', phone: '+1' },
|
|
27
|
+
{ userId: 'b', pin: 'pinB' },
|
|
28
|
+
])
|
|
29
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
30
|
+
const users = JSON.parse(readFileSync(f, 'utf-8'))
|
|
31
|
+
expect(users[0].accessHash).toMatch(/^[0-9a-f]+:[0-9a-f]+$/)
|
|
32
|
+
expect(users[0].pin).toBe('pinA')
|
|
33
|
+
expect(users[0].phone).toBe('+1')
|
|
34
|
+
expect(users[1].accessHash).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('verifyAccessPassword returns the matching userId, null on miss', async () => {
|
|
38
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }])
|
|
39
|
+
await setAccessPassword('b', 'Sw0rdfish!', f)
|
|
40
|
+
expect(await verifyAccessPassword('Sw0rdfish!', f)).toBe('b')
|
|
41
|
+
expect(await verifyAccessPassword('wrong', f)).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("clearAccessPassword removes only that record's hash", async () => {
|
|
45
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }])
|
|
46
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
47
|
+
clearAccessPassword('a', f)
|
|
48
|
+
const users = JSON.parse(readFileSync(f, 'utf-8'))
|
|
49
|
+
expect(users[0].accessHash).toBeUndefined()
|
|
50
|
+
expect(users[0].pin).toBe('x')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('anyAccessPasswordConfigured reflects presence', async () => {
|
|
54
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }])
|
|
55
|
+
expect(anyAccessPasswordConfigured(f)).toBe(false)
|
|
56
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
57
|
+
expect(anyAccessPasswordConfigured(f)).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('verifyAccessPassword returns null when no record has accessHash', async () => {
|
|
61
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }])
|
|
62
|
+
expect(await verifyAccessPassword('anything', f)).toBeNull()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('accessPasswordCollides: true when a DIFFERENT admin already has that password', async () => {
|
|
66
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }])
|
|
67
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
68
|
+
// b is choosing the same password a already uses
|
|
69
|
+
expect(await accessPasswordCollides('Sw0rdfish!', f, 'b')).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('accessPasswordCollides: false when only the excluded admin holds that password', async () => {
|
|
73
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }])
|
|
74
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
75
|
+
// a rotating to the same value it already has is not a cross-admin collision
|
|
76
|
+
expect(await accessPasswordCollides('Sw0rdfish!', f, 'a')).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('accessPasswordCollides: false for a password no other admin uses', async () => {
|
|
80
|
+
const f = tmpUsers([{ userId: 'a', pin: 'x' }, { userId: 'b', pin: 'y' }])
|
|
81
|
+
await setAccessPassword('a', 'Sw0rdfish!', f)
|
|
82
|
+
expect(await accessPasswordCollides('Different1!', f, 'b')).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('accessPasswordCollides: false when the store is unreadable', async () => {
|
|
86
|
+
const dir = mkdtempSync(join(tmpdir(), 'aapc-'))
|
|
87
|
+
const corrupt = join(dir, 'users.json')
|
|
88
|
+
writeFileSync(corrupt, '{ not json')
|
|
89
|
+
expect(await accessPasswordCollides('anything', corrupt, 'b')).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('accessStoreUnreadable: false for absent/clean, true for corrupt (fail-closed signal)', () => {
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), 'aapu-'))
|
|
94
|
+
const absent = join(dir, 'users.json')
|
|
95
|
+
expect(accessStoreUnreadable(absent)).toBe(false) // absent → not unreadable
|
|
96
|
+
writeFileSync(absent, JSON.stringify([{ userId: 'a', pin: 'x' }]))
|
|
97
|
+
expect(accessStoreUnreadable(absent)).toBe(false) // clean → readable
|
|
98
|
+
writeFileSync(absent, '{ this is not json')
|
|
99
|
+
expect(accessStoreUnreadable(absent)).toBe(true) // corrupt → unreadable → caller fails closed
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('migrateLegacyRemotePassword', () => {
|
|
104
|
+
function setup(usersContent: unknown, legacy?: string): { usersFile: string; legacyFile: string } {
|
|
105
|
+
const dir = mkdtempSync(join(tmpdir(), 'aapm-'))
|
|
106
|
+
const usersFile = join(dir, 'users.json')
|
|
107
|
+
const legacyFile = join(dir, '.remote-password')
|
|
108
|
+
writeFileSync(usersFile, JSON.stringify(usersContent, null, 2))
|
|
109
|
+
if (legacy !== undefined) writeFileSync(legacyFile, legacy)
|
|
110
|
+
return { usersFile, legacyFile }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('moves the legacy hash into the owner record and deletes the file', async () => {
|
|
114
|
+
const legacyHash = await hashAccessPassword('OwnerPass1!')
|
|
115
|
+
const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x' }], legacyHash)
|
|
116
|
+
const r = migrateLegacyRemotePassword(usersFile, legacyFile, 'owner')
|
|
117
|
+
expect(r).toBe('migrated')
|
|
118
|
+
expect(existsSync(legacyFile)).toBe(false)
|
|
119
|
+
const users = JSON.parse(readFileSync(usersFile, 'utf-8'))
|
|
120
|
+
expect(users[0].accessHash).toBe(legacyHash)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('is idempotent — second run is noop', async () => {
|
|
124
|
+
const legacyHash = await hashAccessPassword('OwnerPass1!')
|
|
125
|
+
const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x' }], legacyHash)
|
|
126
|
+
migrateLegacyRemotePassword(usersFile, legacyFile, 'owner')
|
|
127
|
+
expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner')).toBe('noop-no-legacy')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('no owner record → leaves the legacy file in place', async () => {
|
|
131
|
+
const legacyHash = await hashAccessPassword('OwnerPass1!')
|
|
132
|
+
const { usersFile, legacyFile } = setup([{ userId: 'someoneelse', pin: 'x' }], legacyHash)
|
|
133
|
+
expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner')).toBe('noop-no-owner')
|
|
134
|
+
expect(existsSync(legacyFile)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('owner already has accessHash → deletes stale legacy file, noop', async () => {
|
|
138
|
+
const { usersFile, legacyFile } = setup([{ userId: 'owner', pin: 'x', accessHash: 'aa:bb' }], 'cc:dd')
|
|
139
|
+
expect(migrateLegacyRemotePassword(usersFile, legacyFile, 'owner')).toBe('noop-owner-has-hash')
|
|
140
|
+
expect(existsSync(legacyFile)).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface AccessUserEntry {
|
|
2
|
+
userId: string;
|
|
3
|
+
pin: string;
|
|
4
|
+
phone?: string;
|
|
5
|
+
accessHash?: string;
|
|
6
|
+
[k: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function hashAccessPassword(password: string): Promise<string>;
|
|
9
|
+
declare function matchesHash(password: string, stored: string): Promise<boolean>;
|
|
10
|
+
/** Write `accessHash` onto the userId's record. Throws if the record is absent. */
|
|
11
|
+
export declare function setAccessPassword(userId: string, password: string, usersFile: string): Promise<void>;
|
|
12
|
+
/** Remove `accessHash` from the userId's record. No-op if absent. */
|
|
13
|
+
export declare function clearAccessPassword(userId: string, usersFile: string): void;
|
|
14
|
+
/** Return the userId whose accessHash matches, or null. */
|
|
15
|
+
export declare function verifyAccessPassword(password: string, usersFile: string): Promise<string | null>;
|
|
16
|
+
/**
|
|
17
|
+
* True when some admin OTHER than excludeUserId already has an accessHash that
|
|
18
|
+
* matches `password`. Used by the set/change surfaces to refuse a value that
|
|
19
|
+
* would collide with another admin's perimeter credential — the scan-based
|
|
20
|
+
* verify attributes a login to the first matching record, so two admins sharing
|
|
21
|
+
* a password lets one silently overwrite the other. An unreadable store returns
|
|
22
|
+
* false: the subsequent write is what owns the read-fault error path.
|
|
23
|
+
*/
|
|
24
|
+
export declare function accessPasswordCollides(password: string, usersFile: string, excludeUserId: string): Promise<boolean>;
|
|
25
|
+
/** True when at least one record carries an accessHash. */
|
|
26
|
+
export declare function anyAccessPasswordConfigured(usersFile: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* True when users.json is present on disk but cannot be read or parsed. The
|
|
29
|
+
* gate's `isRemoteAuthConfigured` checks this so a transient read fault on a
|
|
30
|
+
* configured install fails CLOSED (stays "configured" → login screen, not the
|
|
31
|
+
* setup flow) instead of downgrading to "unconfigured". A genuinely absent or
|
|
32
|
+
* cleanly-empty store is NOT unreadable.
|
|
33
|
+
*/
|
|
34
|
+
export declare function accessStoreUnreadable(usersFile: string): boolean;
|
|
35
|
+
export type MigrateResult = 'migrated' | 'noop-no-legacy' | 'noop-no-owner' | 'noop-owner-has-hash';
|
|
36
|
+
/**
|
|
37
|
+
* One-time, idempotent. If the legacy single-password file exists and the
|
|
38
|
+
* owner record has no accessHash, copy the legacy hash (already scrypt
|
|
39
|
+
* "salt:hash") onto the owner and delete the legacy file. If the owner already
|
|
40
|
+
* has an accessHash, the legacy file is stale → delete it. If no owner record
|
|
41
|
+
* exists yet, leave the legacy file untouched (verify still reads it as a
|
|
42
|
+
* candidate during this window — see readLegacyRemoteHash).
|
|
43
|
+
*/
|
|
44
|
+
export declare function migrateLegacyRemotePassword(usersFile: string, legacyFile: string, ownerUserId: string): MigrateResult;
|
|
45
|
+
/** Read a still-present legacy single-password hash, or null. Used by verify
|
|
46
|
+
* as one additional candidate before the boot migration runs. */
|
|
47
|
+
export declare function readLegacyRemoteHash(legacyFile: string): string | null;
|
|
48
|
+
export { matchesHash as verifyHashCandidate };
|
|
49
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACrB;AAWD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAI1E;AAED,iBAAe,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAS7E;AAiBD,mFAAmF;AACnF,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM1G;AAED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAQ3E;AAED,2DAA2D;AAC3D,wBAAsB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAStG;AAED;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAC1C,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,OAAO,CAAC,CAUlB;AAED,2DAA2D;AAC3D,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAItE;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAGhE;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,gBAAgB,GAAG,eAAe,GAAG,qBAAqB,CAAA;AAEnG;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,aAAa,CAgBf;AAED;kEACkE;AAClE,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAItE;AAED,OAAO,EAAE,WAAW,IAAI,mBAAmB,EAAE,CAAA"}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hashAccessPassword = hashAccessPassword;
|
|
4
|
+
exports.setAccessPassword = setAccessPassword;
|
|
5
|
+
exports.clearAccessPassword = clearAccessPassword;
|
|
6
|
+
exports.verifyAccessPassword = verifyAccessPassword;
|
|
7
|
+
exports.accessPasswordCollides = accessPasswordCollides;
|
|
8
|
+
exports.anyAccessPasswordConfigured = anyAccessPasswordConfigured;
|
|
9
|
+
exports.accessStoreUnreadable = accessStoreUnreadable;
|
|
10
|
+
exports.migrateLegacyRemotePassword = migrateLegacyRemotePassword;
|
|
11
|
+
exports.readLegacyRemoteHash = readLegacyRemoteHash;
|
|
12
|
+
exports.verifyHashCandidate = matchesHash;
|
|
13
|
+
// Per-admin device access passwords. Single owner of the `accessHash` field
|
|
14
|
+
// on users.json admin records. Imported by both platform/ui (src) and the
|
|
15
|
+
// admin MCP plugin (dist) — mirrors platform/lib/admins-write.
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const SCRYPT_N = 16384;
|
|
19
|
+
const SCRYPT_R = 8;
|
|
20
|
+
const SCRYPT_P = 1;
|
|
21
|
+
const SCRYPT_KEYLEN = 64;
|
|
22
|
+
function scryptAsync(password, salt) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
(0, node_crypto_1.scrypt)(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
|
|
25
|
+
if (err)
|
|
26
|
+
reject(err);
|
|
27
|
+
else
|
|
28
|
+
resolve(key);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function hashAccessPassword(password) {
|
|
33
|
+
const salt = (0, node_crypto_1.randomBytes)(32);
|
|
34
|
+
const hash = await scryptAsync(password, salt);
|
|
35
|
+
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
|
36
|
+
}
|
|
37
|
+
async function matchesHash(password, stored) {
|
|
38
|
+
const colon = stored.indexOf(':');
|
|
39
|
+
if (colon === -1)
|
|
40
|
+
return false;
|
|
41
|
+
const salt = Buffer.from(stored.slice(0, colon), 'hex');
|
|
42
|
+
const storedHash = Buffer.from(stored.slice(colon + 1), 'hex');
|
|
43
|
+
if (storedHash.length !== SCRYPT_KEYLEN)
|
|
44
|
+
return false;
|
|
45
|
+
let computed;
|
|
46
|
+
try {
|
|
47
|
+
computed = await scryptAsync(password, salt);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return (0, node_crypto_1.timingSafeEqual)(computed, storedHash);
|
|
53
|
+
}
|
|
54
|
+
function readUsers(usersFile) {
|
|
55
|
+
if (!(0, node_fs_1.existsSync)(usersFile))
|
|
56
|
+
return [];
|
|
57
|
+
const raw = (0, node_fs_1.readFileSync)(usersFile, 'utf-8').trim();
|
|
58
|
+
if (!raw)
|
|
59
|
+
return [];
|
|
60
|
+
const parsed = JSON.parse(raw);
|
|
61
|
+
if (!Array.isArray(parsed))
|
|
62
|
+
throw new Error('users.json is not an array');
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
function writeUsersAtomic(usersFile, users) {
|
|
66
|
+
const tmp = `${usersFile}.tmp-${process.pid}`;
|
|
67
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 });
|
|
68
|
+
(0, node_fs_1.renameSync)(tmp, usersFile);
|
|
69
|
+
}
|
|
70
|
+
/** Write `accessHash` onto the userId's record. Throws if the record is absent. */
|
|
71
|
+
async function setAccessPassword(userId, password, usersFile) {
|
|
72
|
+
const users = readUsers(usersFile);
|
|
73
|
+
const idx = users.findIndex(u => u.userId === userId);
|
|
74
|
+
if (idx === -1)
|
|
75
|
+
throw new Error(`no users.json record for userId=${userId}`);
|
|
76
|
+
users[idx] = { ...users[idx], accessHash: await hashAccessPassword(password) };
|
|
77
|
+
writeUsersAtomic(usersFile, users);
|
|
78
|
+
}
|
|
79
|
+
/** Remove `accessHash` from the userId's record. No-op if absent. */
|
|
80
|
+
function clearAccessPassword(userId, usersFile) {
|
|
81
|
+
const users = readUsers(usersFile);
|
|
82
|
+
const idx = users.findIndex(u => u.userId === userId);
|
|
83
|
+
if (idx === -1)
|
|
84
|
+
return;
|
|
85
|
+
const { accessHash, ...rest } = users[idx];
|
|
86
|
+
void accessHash;
|
|
87
|
+
users[idx] = rest;
|
|
88
|
+
writeUsersAtomic(usersFile, users);
|
|
89
|
+
}
|
|
90
|
+
/** Return the userId whose accessHash matches, or null. */
|
|
91
|
+
async function verifyAccessPassword(password, usersFile) {
|
|
92
|
+
let users;
|
|
93
|
+
try {
|
|
94
|
+
users = readUsers(usersFile);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
for (const u of users) {
|
|
100
|
+
if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) {
|
|
101
|
+
return u.userId;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* True when some admin OTHER than excludeUserId already has an accessHash that
|
|
108
|
+
* matches `password`. Used by the set/change surfaces to refuse a value that
|
|
109
|
+
* would collide with another admin's perimeter credential — the scan-based
|
|
110
|
+
* verify attributes a login to the first matching record, so two admins sharing
|
|
111
|
+
* a password lets one silently overwrite the other. An unreadable store returns
|
|
112
|
+
* false: the subsequent write is what owns the read-fault error path.
|
|
113
|
+
*/
|
|
114
|
+
async function accessPasswordCollides(password, usersFile, excludeUserId) {
|
|
115
|
+
let users;
|
|
116
|
+
try {
|
|
117
|
+
users = readUsers(usersFile);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
for (const u of users) {
|
|
123
|
+
if (u.userId === excludeUserId)
|
|
124
|
+
continue;
|
|
125
|
+
if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/** True when at least one record carries an accessHash. */
|
|
132
|
+
function anyAccessPasswordConfigured(usersFile) {
|
|
133
|
+
let users;
|
|
134
|
+
try {
|
|
135
|
+
users = readUsers(usersFile);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return users.some(u => typeof u.accessHash === 'string' && u.accessHash.length > 0);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* True when users.json is present on disk but cannot be read or parsed. The
|
|
144
|
+
* gate's `isRemoteAuthConfigured` checks this so a transient read fault on a
|
|
145
|
+
* configured install fails CLOSED (stays "configured" → login screen, not the
|
|
146
|
+
* setup flow) instead of downgrading to "unconfigured". A genuinely absent or
|
|
147
|
+
* cleanly-empty store is NOT unreadable.
|
|
148
|
+
*/
|
|
149
|
+
function accessStoreUnreadable(usersFile) {
|
|
150
|
+
if (!(0, node_fs_1.existsSync)(usersFile))
|
|
151
|
+
return false;
|
|
152
|
+
try {
|
|
153
|
+
readUsers(usersFile);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* One-time, idempotent. If the legacy single-password file exists and the
|
|
162
|
+
* owner record has no accessHash, copy the legacy hash (already scrypt
|
|
163
|
+
* "salt:hash") onto the owner and delete the legacy file. If the owner already
|
|
164
|
+
* has an accessHash, the legacy file is stale → delete it. If no owner record
|
|
165
|
+
* exists yet, leave the legacy file untouched (verify still reads it as a
|
|
166
|
+
* candidate during this window — see readLegacyRemoteHash).
|
|
167
|
+
*/
|
|
168
|
+
function migrateLegacyRemotePassword(usersFile, legacyFile, ownerUserId) {
|
|
169
|
+
if (!(0, node_fs_1.existsSync)(legacyFile))
|
|
170
|
+
return 'noop-no-legacy';
|
|
171
|
+
const legacy = (0, node_fs_1.readFileSync)(legacyFile, 'utf-8').trim();
|
|
172
|
+
const users = readUsers(usersFile);
|
|
173
|
+
const idx = users.findIndex(u => u.userId === ownerUserId);
|
|
174
|
+
if (idx === -1)
|
|
175
|
+
return 'noop-no-owner';
|
|
176
|
+
const existing = users[idx].accessHash;
|
|
177
|
+
if (typeof existing === 'string' && existing.length > 0) {
|
|
178
|
+
(0, node_fs_1.unlinkSync)(legacyFile);
|
|
179
|
+
return 'noop-owner-has-hash';
|
|
180
|
+
}
|
|
181
|
+
if (!legacy.includes(':')) {
|
|
182
|
+
(0, node_fs_1.unlinkSync)(legacyFile);
|
|
183
|
+
return 'noop-no-legacy';
|
|
184
|
+
}
|
|
185
|
+
users[idx] = { ...users[idx], accessHash: legacy };
|
|
186
|
+
writeUsersAtomic(usersFile, users);
|
|
187
|
+
(0, node_fs_1.unlinkSync)(legacyFile);
|
|
188
|
+
return 'migrated';
|
|
189
|
+
}
|
|
190
|
+
/** Read a still-present legacy single-password hash, or null. Used by verify
|
|
191
|
+
* as one additional candidate before the boot migration runs. */
|
|
192
|
+
function readLegacyRemoteHash(legacyFile) {
|
|
193
|
+
if (!(0, node_fs_1.existsSync)(legacyFile))
|
|
194
|
+
return null;
|
|
195
|
+
const raw = (0, node_fs_1.readFileSync)(legacyFile, 'utf-8').trim();
|
|
196
|
+
return raw.includes(':') ? raw : null;
|
|
197
|
+
}
|
|
198
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Per-admin device access passwords. Single owner of the `accessHash` field
|
|
2
|
+
// on users.json admin records. Imported by both platform/ui (src) and the
|
|
3
|
+
// admin MCP plugin (dist) — mirrors platform/lib/admins-write.
|
|
4
|
+
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const SCRYPT_N = 16384
|
|
8
|
+
const SCRYPT_R = 8
|
|
9
|
+
const SCRYPT_P = 1
|
|
10
|
+
const SCRYPT_KEYLEN = 64
|
|
11
|
+
|
|
12
|
+
export interface AccessUserEntry {
|
|
13
|
+
userId: string
|
|
14
|
+
pin: string
|
|
15
|
+
phone?: string
|
|
16
|
+
accessHash?: string
|
|
17
|
+
[k: string]: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scryptAsync(password: string, salt: Buffer): Promise<Buffer> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
scrypt(password, salt, SCRYPT_KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
|
|
23
|
+
if (err) reject(err)
|
|
24
|
+
else resolve(key)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function hashAccessPassword(password: string): Promise<string> {
|
|
30
|
+
const salt = randomBytes(32)
|
|
31
|
+
const hash = await scryptAsync(password, salt)
|
|
32
|
+
return `${salt.toString('hex')}:${hash.toString('hex')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function matchesHash(password: string, stored: string): Promise<boolean> {
|
|
36
|
+
const colon = stored.indexOf(':')
|
|
37
|
+
if (colon === -1) return false
|
|
38
|
+
const salt = Buffer.from(stored.slice(0, colon), 'hex')
|
|
39
|
+
const storedHash = Buffer.from(stored.slice(colon + 1), 'hex')
|
|
40
|
+
if (storedHash.length !== SCRYPT_KEYLEN) return false
|
|
41
|
+
let computed: Buffer
|
|
42
|
+
try { computed = await scryptAsync(password, salt) } catch { return false }
|
|
43
|
+
return timingSafeEqual(computed, storedHash)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readUsers(usersFile: string): AccessUserEntry[] {
|
|
47
|
+
if (!existsSync(usersFile)) return []
|
|
48
|
+
const raw = readFileSync(usersFile, 'utf-8').trim()
|
|
49
|
+
if (!raw) return []
|
|
50
|
+
const parsed = JSON.parse(raw)
|
|
51
|
+
if (!Array.isArray(parsed)) throw new Error('users.json is not an array')
|
|
52
|
+
return parsed as AccessUserEntry[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeUsersAtomic(usersFile: string, users: AccessUserEntry[]): void {
|
|
56
|
+
const tmp = `${usersFile}.tmp-${process.pid}`
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(users, null, 2) + '\n', { mode: 0o600 })
|
|
58
|
+
renameSync(tmp, usersFile)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write `accessHash` onto the userId's record. Throws if the record is absent. */
|
|
62
|
+
export async function setAccessPassword(userId: string, password: string, usersFile: string): Promise<void> {
|
|
63
|
+
const users = readUsers(usersFile)
|
|
64
|
+
const idx = users.findIndex(u => u.userId === userId)
|
|
65
|
+
if (idx === -1) throw new Error(`no users.json record for userId=${userId}`)
|
|
66
|
+
users[idx] = { ...users[idx], accessHash: await hashAccessPassword(password) }
|
|
67
|
+
writeUsersAtomic(usersFile, users)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Remove `accessHash` from the userId's record. No-op if absent. */
|
|
71
|
+
export function clearAccessPassword(userId: string, usersFile: string): void {
|
|
72
|
+
const users = readUsers(usersFile)
|
|
73
|
+
const idx = users.findIndex(u => u.userId === userId)
|
|
74
|
+
if (idx === -1) return
|
|
75
|
+
const { accessHash, ...rest } = users[idx]
|
|
76
|
+
void accessHash
|
|
77
|
+
users[idx] = rest as AccessUserEntry
|
|
78
|
+
writeUsersAtomic(usersFile, users)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Return the userId whose accessHash matches, or null. */
|
|
82
|
+
export async function verifyAccessPassword(password: string, usersFile: string): Promise<string | null> {
|
|
83
|
+
let users: AccessUserEntry[]
|
|
84
|
+
try { users = readUsers(usersFile) } catch { return null }
|
|
85
|
+
for (const u of users) {
|
|
86
|
+
if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) {
|
|
87
|
+
return u.userId
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* True when some admin OTHER than excludeUserId already has an accessHash that
|
|
95
|
+
* matches `password`. Used by the set/change surfaces to refuse a value that
|
|
96
|
+
* would collide with another admin's perimeter credential — the scan-based
|
|
97
|
+
* verify attributes a login to the first matching record, so two admins sharing
|
|
98
|
+
* a password lets one silently overwrite the other. An unreadable store returns
|
|
99
|
+
* false: the subsequent write is what owns the read-fault error path.
|
|
100
|
+
*/
|
|
101
|
+
export async function accessPasswordCollides(
|
|
102
|
+
password: string,
|
|
103
|
+
usersFile: string,
|
|
104
|
+
excludeUserId: string,
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
let users: AccessUserEntry[]
|
|
107
|
+
try { users = readUsers(usersFile) } catch { return false }
|
|
108
|
+
for (const u of users) {
|
|
109
|
+
if (u.userId === excludeUserId) continue
|
|
110
|
+
if (typeof u.accessHash === 'string' && (await matchesHash(password, u.accessHash))) {
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True when at least one record carries an accessHash. */
|
|
118
|
+
export function anyAccessPasswordConfigured(usersFile: string): boolean {
|
|
119
|
+
let users: AccessUserEntry[]
|
|
120
|
+
try { users = readUsers(usersFile) } catch { return false }
|
|
121
|
+
return users.some(u => typeof u.accessHash === 'string' && u.accessHash.length > 0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* True when users.json is present on disk but cannot be read or parsed. The
|
|
126
|
+
* gate's `isRemoteAuthConfigured` checks this so a transient read fault on a
|
|
127
|
+
* configured install fails CLOSED (stays "configured" → login screen, not the
|
|
128
|
+
* setup flow) instead of downgrading to "unconfigured". A genuinely absent or
|
|
129
|
+
* cleanly-empty store is NOT unreadable.
|
|
130
|
+
*/
|
|
131
|
+
export function accessStoreUnreadable(usersFile: string): boolean {
|
|
132
|
+
if (!existsSync(usersFile)) return false
|
|
133
|
+
try { readUsers(usersFile); return false } catch { return true }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type MigrateResult = 'migrated' | 'noop-no-legacy' | 'noop-no-owner' | 'noop-owner-has-hash'
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One-time, idempotent. If the legacy single-password file exists and the
|
|
140
|
+
* owner record has no accessHash, copy the legacy hash (already scrypt
|
|
141
|
+
* "salt:hash") onto the owner and delete the legacy file. If the owner already
|
|
142
|
+
* has an accessHash, the legacy file is stale → delete it. If no owner record
|
|
143
|
+
* exists yet, leave the legacy file untouched (verify still reads it as a
|
|
144
|
+
* candidate during this window — see readLegacyRemoteHash).
|
|
145
|
+
*/
|
|
146
|
+
export function migrateLegacyRemotePassword(
|
|
147
|
+
usersFile: string,
|
|
148
|
+
legacyFile: string,
|
|
149
|
+
ownerUserId: string,
|
|
150
|
+
): MigrateResult {
|
|
151
|
+
if (!existsSync(legacyFile)) return 'noop-no-legacy'
|
|
152
|
+
const legacy = readFileSync(legacyFile, 'utf-8').trim()
|
|
153
|
+
const users = readUsers(usersFile)
|
|
154
|
+
const idx = users.findIndex(u => u.userId === ownerUserId)
|
|
155
|
+
if (idx === -1) return 'noop-no-owner'
|
|
156
|
+
const existing = users[idx].accessHash
|
|
157
|
+
if (typeof existing === 'string' && existing.length > 0) {
|
|
158
|
+
unlinkSync(legacyFile)
|
|
159
|
+
return 'noop-owner-has-hash'
|
|
160
|
+
}
|
|
161
|
+
if (!legacy.includes(':')) { unlinkSync(legacyFile); return 'noop-no-legacy' }
|
|
162
|
+
users[idx] = { ...users[idx], accessHash: legacy }
|
|
163
|
+
writeUsersAtomic(usersFile, users)
|
|
164
|
+
unlinkSync(legacyFile)
|
|
165
|
+
return 'migrated'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Read a still-present legacy single-password hash, or null. Used by verify
|
|
169
|
+
* as one additional candidate before the boot migration runs. */
|
|
170
|
+
export function readLegacyRemoteHash(legacyFile: string): string | null {
|
|
171
|
+
if (!existsSync(legacyFile)) return null
|
|
172
|
+
const raw = readFileSync(legacyFile, 'utf-8').trim()
|
|
173
|
+
return raw.includes(':') ? raw : null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { matchesHash as verifyHashCandidate }
|