@tequila99/githuman 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +50 -9
  2. package/dist/cli/config.js +35 -2
  3. package/dist/cli/index.js +2 -1
  4. package/dist/cli/server-runtime.js +14 -11
  5. package/dist/cli/startup-message.js +5 -4
  6. package/dist/server/app.js +48 -5
  7. package/dist/server/db/migrations.js +19 -0
  8. package/dist/server/errors/devices.js +7 -0
  9. package/dist/server/errors/http.js +1 -0
  10. package/dist/server/hooks/access/local-request.js +21 -0
  11. package/dist/server/hooks/access/require-access.js +93 -0
  12. package/dist/server/hooks/agents/local-origin.js +9 -26
  13. package/dist/server/hooks/notes/same-origin.js +7 -34
  14. package/dist/server/hooks/terminal/local-origin.js +8 -22
  15. package/dist/server/repositories/device.repo.js +74 -0
  16. package/dist/server/routes/agent.js +11 -2
  17. package/dist/server/routes/devices.js +130 -0
  18. package/dist/server/routes/events.js +7 -1
  19. package/dist/server/routes/notes.js +3 -3
  20. package/dist/server/routes/terminal.js +10 -2
  21. package/dist/server/services/device-connections.js +79 -0
  22. package/dist/server/services/device.service.js +222 -0
  23. package/dist/server/services/notes-external-assets.service.js +5 -5
  24. package/dist/server/services/notes-identity.service.js +4 -2
  25. package/dist/server/services/terminal/tokens.js +1 -1
  26. package/dist/server/utils/address-key.js +33 -0
  27. package/dist/server/utils/allowed-hosts.js +38 -0
  28. package/dist/server/utils/device-name.js +27 -0
  29. package/dist/server/utils/lan-addresses.js +35 -0
  30. package/dist/server/utils/remote-asset-request.js +9 -2
  31. package/dist/server/utils/request-origin.js +105 -0
  32. package/dist/shared/devices/constants.js +16 -0
  33. package/dist/shared/devices/name.js +12 -0
  34. package/dist/shared/devices/schemas.js +67 -0
  35. package/dist/shared/devices/types.js +1 -0
  36. package/dist/shared/events/types.js +2 -1
  37. package/dist/shared/network/address-url.js +4 -0
  38. package/dist/shared/utils/date.js +3 -0
  39. package/dist/web/assets/AccessPage-_OKn9U0P.js +1 -0
  40. package/dist/web/assets/AccessPage-l5Yiiw9T.css +1 -0
  41. package/dist/web/assets/AppPage-oQ9Rz0be.js +1 -0
  42. package/dist/web/assets/CreateButton-D1EtoicG.js +1 -0
  43. package/dist/web/assets/CreateReviewFabButton-B2Td8Wno.css +1 -0
  44. package/dist/web/assets/CreateReviewFabButton-BxvT3i9e.js +1 -0
  45. package/dist/web/assets/ErrorNotFound-DtnO85PU.js +1 -0
  46. package/dist/web/assets/IndexPage-D5PvyGrX.css +1 -0
  47. package/dist/web/assets/IndexPage-gdD7BHxA.js +1 -0
  48. package/dist/web/assets/MainLayout-47PTLKZf.js +6 -0
  49. package/dist/web/assets/MainLayout-ikSrFqBh.css +1 -0
  50. package/dist/web/assets/NoteVisualEditor-BQ6mVfj0.js +1054 -0
  51. package/dist/web/assets/{NoteVisualEditor-IH3s3d3W.css → NoteVisualEditor-BYikrY7Q.css} +1 -1
  52. package/dist/web/assets/NotesPage-DNR9iW_G.js +4 -0
  53. package/dist/web/assets/NotesPage-_8naQciW.css +1 -0
  54. package/dist/web/assets/PanelResize-CAQFVc8b.js +1 -0
  55. package/dist/web/assets/PanelResize-XjrbOj2S.css +1 -0
  56. package/dist/web/assets/{PdfPreview-DgwnGJ2E.js → PdfPreview-kL_nk9my.js} +2 -2
  57. package/dist/web/assets/PdfPreview-l_xzsLJ_.css +1 -0
  58. package/dist/web/assets/{QBtn-DoggJe1Q.js → QBtn-CB-yw_iP.js} +1 -1
  59. package/dist/web/assets/QExpansionItem-Dr5oXO0m.js +4 -0
  60. package/dist/web/assets/QFab-DPz8RiP0.js +1 -0
  61. package/dist/web/assets/QForm-CASiKhGm.js +1 -0
  62. package/dist/web/assets/{QInput-DJgTh2Qy.js → QInput-k34nCPWg.js} +1 -1
  63. package/dist/web/assets/QSelect-CaKn9Tkg.js +1 -0
  64. package/dist/web/assets/QSpace-Di_bZQY6.js +1 -0
  65. package/dist/web/assets/QVirtualScroll-oGN_Tl5J.js +1 -0
  66. package/dist/web/assets/ReviewDetailPage-BKgIprY0.js +1 -0
  67. package/dist/web/assets/ReviewsPage-6PJ6wofN.js +1 -0
  68. package/dist/web/assets/{StatusSwitcher-BCv_9MtI.css → StatusSwitcher-D6i8_OfR.css} +1 -1
  69. package/dist/web/assets/StatusSwitcher-s9w6iKpR.js +1 -0
  70. package/dist/web/assets/{TerminalView-C1jFlfLH.css → TerminalView-617DZuPt.css} +1 -1
  71. package/dist/web/assets/TerminalView-BsO_SZut.js +19 -0
  72. package/dist/web/assets/{abnfDiagram-O67JEVCF-BYlDf7LI.js → abnfDiagram-O67JEVCF-qUSrlp_c.js} +1 -1
  73. package/dist/web/assets/access-store-Q8nkv4Nb.js +1 -0
  74. package/dist/web/assets/agent-store-D2jgeBgi.js +113 -0
  75. package/dist/web/assets/architecture-7GRP2DOG-D6PShS0_.js +1 -0
  76. package/dist/web/assets/{architectureDiagram-NJMV4G6O-CC0SmfK_.js → architectureDiagram-NJMV4G6O-D7RC6OS2.js} +1 -1
  77. package/dist/web/assets/{blockDiagram-BEXU5L5S-C1Ny76Gc.js → blockDiagram-BEXU5L5S-CmNtTpfK.js} +1 -1
  78. package/dist/web/assets/{c4Diagram-YGBWAQC7-CP2if62Y.js → c4Diagram-YGBWAQC7-CKCcu1qx.js} +1 -1
  79. package/dist/web/assets/channel-D6lb5DbY.js +1 -0
  80. package/dist/web/assets/{chunk-3FUC2YCW-cjM6hj3T.js → chunk-3FUC2YCW-Drz6oeKx.js} +1 -1
  81. package/dist/web/assets/{chunk-5DYCD2WN-DAW_EwWQ.js → chunk-5DYCD2WN-RItzEgV_.js} +1 -1
  82. package/dist/web/assets/{chunk-742MDFTN-nkStFRF-.js → chunk-742MDFTN-D6hKdOv7.js} +1 -1
  83. package/dist/web/assets/{chunk-7INBJB4K-EQQsEy8I.js → chunk-7INBJB4K-Dk7-Eior.js} +1 -1
  84. package/dist/web/assets/{chunk-7M6MHVWA-DUJ2C21w.js → chunk-7M6MHVWA-Cw8E0Nkn.js} +1 -1
  85. package/dist/web/assets/{chunk-7PRAP22T-C1Bj5-NO.js → chunk-7PRAP22T-t-mTeNXF.js} +1 -1
  86. package/dist/web/assets/{chunk-GTNCS2PH-DW39t1bj.js → chunk-GTNCS2PH-C7j2atV8.js} +1 -1
  87. package/dist/web/assets/{chunk-GWA4HPMP-i49YwHnS.js → chunk-GWA4HPMP-CH9T9LZO.js} +1 -1
  88. package/dist/web/assets/{chunk-MBY4JIJT-C4mXlV61.js → chunk-MBY4JIJT-yXOO37xD.js} +1 -1
  89. package/dist/web/assets/{chunk-NETBCI7D-DoRSO-kq.js → chunk-NETBCI7D-C-s2UtAR.js} +1 -1
  90. package/dist/web/assets/{chunk-O7XYJQB3-D38ZePQT.js → chunk-O7XYJQB3-5Me_aV8o.js} +1 -1
  91. package/dist/web/assets/{chunk-UA2S7LBM-DmOI-u6c.js → chunk-UA2S7LBM-BQaibeff.js} +1 -1
  92. package/dist/web/assets/{chunk-WEXAMYUT-Dv710fF3.js → chunk-WEXAMYUT-UBcOE4Lf.js} +1 -1
  93. package/dist/web/assets/{chunk-Z7XXMR3K-CWHlAHIu.js → chunk-Z7XXMR3K-BsA8o3Pt.js} +1 -1
  94. package/dist/web/assets/{chunk-ZIGJFQKS-Bid5tGRk.js → chunk-ZIGJFQKS--KwSI1U0.js} +1 -1
  95. package/dist/web/assets/{classDiagram-v2-NBCMYWYE-C1770G1t.js → classDiagram-v2-NBCMYWYE-pMF8oCmq.js} +1 -1
  96. package/dist/web/assets/create-B9Tn5Xvi.js +1 -0
  97. package/dist/web/assets/{cynefin-OW5HDTMX-CWxF4ZIN.js → cynefin-OW5HDTMX-DFgtXolF.js} +1 -1
  98. package/dist/web/assets/{cynefinDiagram-VND7K2PF-DcFBTI6m.js → cynefinDiagram-VND7K2PF-CPY0JRyq.js} +1 -1
  99. package/dist/web/assets/{dagre-6A5THRUB-CP5wTvgN.js → dagre-6A5THRUB-KdjJKAB7.js} +1 -1
  100. package/dist/web/assets/devices-store-CrZ4MxyY.js +1 -0
  101. package/dist/web/assets/{diagram-22UHCM2B-mLUm1v32.js → diagram-22UHCM2B-BDcOcvF_.js} +1 -1
  102. package/dist/web/assets/{diagram-3UASUU5V-CP0OFpuS.js → diagram-3UASUU5V-6j2nlZL3.js} +1 -1
  103. package/dist/web/assets/{diagram-ATOU4E4O-DolFulHH.js → diagram-ATOU4E4O-BN8cdxED.js} +1 -1
  104. package/dist/web/assets/{diagram-CDSNMT55-Bs17AhwI.js → diagram-CDSNMT55-cWPiXHkr.js} +1 -1
  105. package/dist/web/assets/{diagram-MLGK6HIB-BfhsJwV7.js → diagram-MLGK6HIB-DcgkH_Vl.js} +1 -1
  106. package/dist/web/assets/{diagram-MPIPVDR6-B1c-Ikp-.js → diagram-MPIPVDR6-D_e86L99.js} +1 -1
  107. package/dist/web/assets/diff-store-ByxMCKoa.js +93 -0
  108. package/dist/web/assets/{dist-CyyHluOV.js → dist-BMFtkY2y.js} +1 -1
  109. package/dist/web/assets/{dist-d8yBJi6g.js → dist-BP40mvwJ.js} +1 -1
  110. package/dist/web/assets/{dist-zqKz9Qrf.js → dist-BVVhgjWT.js} +1 -1
  111. package/dist/web/assets/{dist-BsjKEQwP.js → dist-Bdsq-Ze1.js} +1 -1
  112. package/dist/web/assets/{dist-Bm8OAr9J.js → dist-BwpyANeW.js} +1 -1
  113. package/dist/web/assets/{dist-DkKvAWYi.js → dist-Bz40Nd9I.js} +1 -1
  114. package/dist/web/assets/{dist-yQ-fH4og.js → dist-C9enVshI.js} +1 -1
  115. package/dist/web/assets/{dist-DuW-c5so.js → dist-CLCP7f3x.js} +1 -1
  116. package/dist/web/assets/{dist-C6YiC_nY.js → dist-CU43ytsh.js} +1 -1
  117. package/dist/web/assets/{dist-g0NXSFab.js → dist-CjkUzU4s.js} +1 -1
  118. package/dist/web/assets/{dist-D6WXR3TF.js → dist-MUQaJRtC.js} +1 -1
  119. package/dist/web/assets/{ebnfDiagram-ZINNZB2B-BvQ60ZD9.js → ebnfDiagram-ZINNZB2B-D8xHOFOi.js} +1 -1
  120. package/dist/web/assets/{elk-276RUBZZ-C27WMGnY.js → elk-276RUBZZ-JiDwNyLy.js} +1 -1
  121. package/dist/web/assets/{erDiagram-OPXOYQCR-CdfkOXb9.js → erDiagram-OPXOYQCR-D3J6u81a.js} +1 -1
  122. package/dist/web/assets/eventmodeling-NTZA5JFV-QCwZLvmm.js +1 -0
  123. package/dist/web/assets/flowDiagram-KWPJA3E3-CgF90hDi.js +1 -0
  124. package/dist/web/assets/{ganttDiagram-FUAMR5RP-CuKNmNbv.js → ganttDiagram-FUAMR5RP-nvAVhbgI.js} +1 -1
  125. package/dist/web/assets/{gitGraph-4MIJSDKK-DBGr6EfP.js → gitGraph-4MIJSDKK-Bv_wkB2T.js} +1 -1
  126. package/dist/web/assets/{gitGraphDiagram-X574FWY7-BUZgiyYd.js → gitGraphDiagram-X574FWY7-DxxwadhH.js} +1 -1
  127. package/dist/web/assets/i18n-DDCqtTTW.js +1 -0
  128. package/dist/web/assets/index-BNHt6zmP.js +2 -0
  129. package/dist/web/assets/index-BwbM_RKi.css +1 -0
  130. package/dist/web/assets/{info-A6RAGUB7-CjnEima-.js → info-A6RAGUB7-Dw18yeIE.js} +1 -1
  131. package/dist/web/assets/{infoDiagram-VRGFBTTK-D91sa8do.js → infoDiagram-VRGFBTTK-C3BrgjYg.js} +1 -1
  132. package/dist/web/assets/init-app-BouXTk8p.js +1 -0
  133. package/dist/web/assets/{is-CE4jkrGQ.js → is-D0waCELe.js} +1 -1
  134. package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-2K-kH65E.js → ishikawaDiagram-OU5B5YK6-DU2p9szX.js} +1 -1
  135. package/dist/web/assets/{journeyDiagram-ZHPQQLJL-CkENPVJA.js → journeyDiagram-ZHPQQLJL-Bs2zQnew.js} +1 -1
  136. package/dist/web/assets/{kanban-definition-PNTS6WVX-BHJODz7s.js → kanban-definition-PNTS6WVX-pqXd5uNi.js} +1 -1
  137. package/dist/web/assets/{line-C6VvIL_x.js → line-CN4Dxpei.js} +1 -1
  138. package/dist/web/assets/{markdown-192Ev3_O.js → markdown-jR0fhe4d.js} +1 -1
  139. package/dist/web/assets/{mermaid-parser.core-DMJg-lnr.js → mermaid-parser.core-DZqDOGRM.js} +3 -3
  140. package/dist/web/assets/{mermaid.core-Dd40xnyQ.js → mermaid.core-CUndCvkz.js} +4 -4
  141. package/dist/web/assets/{mindmap-definition-NLK3R4M7-DhfAMSga.js → mindmap-definition-NLK3R4M7-DyK7YqB7.js} +1 -1
  142. package/dist/web/assets/{packet-AYTQ26CC-DZUk7rEw.js → packet-AYTQ26CC-DvlW_0BP.js} +1 -1
  143. package/dist/web/assets/{pegDiagram-GJSIUBJH-W22cBCh2.js → pegDiagram-GJSIUBJH-k2e_-FWQ.js} +1 -1
  144. package/dist/web/assets/{pie-WAS4IAKB-BMvwPiHK.js → pie-WAS4IAKB-B99zaYjO.js} +1 -1
  145. package/dist/web/assets/{pieDiagram-5QR66LMP-CZ2EVCmx.js → pieDiagram-5QR66LMP-BPbS05lK.js} +1 -1
  146. package/dist/web/assets/pinia-BETEFLDG.js +1 -0
  147. package/dist/web/assets/pinia-DO0AdKGg.js +1 -0
  148. package/dist/web/assets/{quadrantDiagram-O4NWA36T-DBS45bbr.js → quadrantDiagram-O4NWA36T-DqNNdsd2.js} +1 -1
  149. package/dist/web/assets/{radar-RG4KPBEZ-BDkD2JF1.js → radar-RG4KPBEZ-BIAAr9LO.js} +1 -1
  150. package/dist/web/assets/{railroad-74A4TZTK-BH_sA5bU.js → railroad-74A4TZTK-DYxr-1-f.js} +1 -1
  151. package/dist/web/assets/railroad-abnf-HS5TGJTU-GESjhc3t.js +1 -0
  152. package/dist/web/assets/railroad-ebnf-LZEXJU2U-Cybu0A7J.js +1 -0
  153. package/dist/web/assets/railroad-peg-WCYAUIDC-DCHxrtbI.js +1 -0
  154. package/dist/web/assets/{railroadDiagram-XR7U4H2S-CrI1TIG1.js → railroadDiagram-XR7U4H2S-TNg-LqRR.js} +1 -1
  155. package/dist/web/assets/{requirementDiagram-PLB6GJNP-BbtaFtGn.js → requirementDiagram-PLB6GJNP-hKAyJR6W.js} +1 -1
  156. package/dist/web/assets/runtime-core.esm-bundler-CAvPiMpd.js +1 -0
  157. package/dist/web/assets/{safe-storage-DekXHbgc.js → safe-storage-B1Aq5CGi.js} +1 -1
  158. package/dist/web/assets/{sankeyDiagram-IPEJSGJF-DRU9tLf3.js → sankeyDiagram-IPEJSGJF-_MSL-tES.js} +1 -1
  159. package/dist/web/assets/{sequenceDiagram-PO4LG4MO-RquVuniP.js → sequenceDiagram-PO4LG4MO-Byq-joBG.js} +1 -1
  160. package/dist/web/assets/single-flight-DLCbzLI3.js +1 -0
  161. package/dist/web/assets/{stateDiagram-v2-GCMORJYK-CKjC8JTS.js → stateDiagram-v2-GCMORJYK-BR2jAgaa.js} +1 -1
  162. package/dist/web/assets/{swimlanes-2SLR337P-B-5FN1NY.js → swimlanes-2SLR337P-Nt8ojGKK.js} +1 -1
  163. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-Ds1hVYqh.js +8 -0
  164. package/dist/web/assets/terminal-store-D0abibMa.js +1 -0
  165. package/dist/web/assets/theme-QEcOl0lf.js +1 -0
  166. package/dist/web/assets/{timeline-definition-EJHVYXUP-DbG_YVkW.js → timeline-definition-EJHVYXUP-BtihLvxk.js} +1 -1
  167. package/dist/web/assets/{treeView-Q6P3EWNA-BgkrW_FP.js → treeView-Q6P3EWNA-D5HzXeKd.js} +1 -1
  168. package/dist/web/assets/{treemap-WGGIJYW6-Dp8ISpo7.js → treemap-WGGIJYW6-Dovjuutg.js} +1 -1
  169. package/dist/web/assets/use-add-agent-context-BaPUIO93.js +1 -0
  170. package/dist/web/assets/use-app-theme-B1cdbnHn.js +1 -0
  171. package/dist/web/assets/use-countdown-CTag4T13.js +1 -0
  172. package/dist/web/assets/use-panel-width-CB-0S3zX.js +1 -0
  173. package/dist/web/assets/use-router-link-Dr9wnnyZ.js +1 -0
  174. package/dist/web/assets/use-row-height-DQHVMtUe.js +1 -0
  175. package/dist/web/assets/use-server-events-BrBCbeHu.js +1 -0
  176. package/dist/web/assets/{use-virtual-scroll-BSXYZjZ5.js → use-virtual-scroll-DG0UKjVk.js} +1 -1
  177. package/dist/web/assets/{usecaseDiagram-POWQR4AR-DwRXMhnD.js → usecaseDiagram-POWQR4AR-DSwRE8zj.js} +1 -1
  178. package/dist/web/assets/{vennDiagram-UO4OBE2U-CxdeHvjU.js → vennDiagram-UO4OBE2U-C-B8yP1K.js} +1 -1
  179. package/dist/web/assets/{vue-i18n.runtime-BpRrzZrT.js → vue-i18n.runtime-CEtihHHL.js} +2 -2
  180. package/dist/web/assets/{wardley-WFR3VGLG-C8gq8AfM.js → wardley-WFR3VGLG-wWgaZuh6.js} +1 -1
  181. package/dist/web/assets/{wardleyDiagram-VNRHLVJA-C9Jo2KAj.js → wardleyDiagram-VNRHLVJA-DbhenrHd.js} +1 -1
  182. package/dist/web/assets/weighted-lru-BSxRnMHR.js +1 -0
  183. package/dist/web/assets/{xychartDiagram-PMCCYNJV-D3tp7hdU.js → xychartDiagram-PMCCYNJV-CxFDmAEt.js} +1 -1
  184. package/dist/web/index.html +8 -8
  185. package/package.json +3 -1
  186. package/dist/web/assets/AppPage-DdmqiV2R.js +0 -1
  187. package/dist/web/assets/ClosePopup-DhjIPp8s.js +0 -1
  188. package/dist/web/assets/CreateButton-BoxoJ7Jr.js +0 -93
  189. package/dist/web/assets/CreateReviewFabButton-CA5OsG7d.js +0 -1
  190. package/dist/web/assets/CreateReviewFabButton-Cufd8hVJ.css +0 -1
  191. package/dist/web/assets/ErrorNotFound-Cq0wNQMu.js +0 -1
  192. package/dist/web/assets/IndexPage-CROp8XNF.js +0 -1
  193. package/dist/web/assets/IndexPage-F8MMAAkn.css +0 -1
  194. package/dist/web/assets/MainLayout-DM30ynlR.css +0 -1
  195. package/dist/web/assets/MainLayout-DvfVDB2G.js +0 -6
  196. package/dist/web/assets/NoteVisualEditor-VfLNFzsD.js +0 -1054
  197. package/dist/web/assets/NotesPage-D_NiwDh8.js +0 -4
  198. package/dist/web/assets/NotesPage-KaCEnfdz.css +0 -1
  199. package/dist/web/assets/PanelResize-9uljPbE4.js +0 -1
  200. package/dist/web/assets/PanelResize-C51WwwLD.css +0 -1
  201. package/dist/web/assets/PdfPreview-Cb0xawi0.css +0 -1
  202. package/dist/web/assets/QFab-D1X33Wdk.js +0 -1
  203. package/dist/web/assets/QForm-BQMj3m1W.js +0 -4
  204. package/dist/web/assets/QResizeObserver-D-9MeQ9q.js +0 -1
  205. package/dist/web/assets/QScrollArea-DlLel0SO.js +0 -1
  206. package/dist/web/assets/QSelect-mw3iS4Cp.js +0 -1
  207. package/dist/web/assets/QSpace-BbB0w38T.js +0 -1
  208. package/dist/web/assets/QVirtualScroll-FtQn3PJj.js +0 -1
  209. package/dist/web/assets/ReviewDetailPage-D7x0R_2N.js +0 -1
  210. package/dist/web/assets/ReviewsPage-C317IA1b.js +0 -1
  211. package/dist/web/assets/StatusSwitcher-BQXo4sss.js +0 -1
  212. package/dist/web/assets/TerminalView-CTsFWvWm.js +0 -19
  213. package/dist/web/assets/agent-store-ByB1OrjP.js +0 -113
  214. package/dist/web/assets/app-info-store-Gn-SNU3Y.js +0 -1
  215. package/dist/web/assets/architecture-7GRP2DOG-DNWQGMIX.js +0 -1
  216. package/dist/web/assets/channel-DW-wL7ua.js +0 -1
  217. package/dist/web/assets/create-BSmXD9Zr.js +0 -1
  218. package/dist/web/assets/eventmodeling-NTZA5JFV-C1sONJTj.js +0 -1
  219. package/dist/web/assets/flowDiagram-KWPJA3E3-CU2TsxFx.js +0 -1
  220. package/dist/web/assets/i18n-Cxfq3B8n.js +0 -1
  221. package/dist/web/assets/index-DKirIJG5.js +0 -2
  222. package/dist/web/assets/index-Dma-RXBR.css +0 -1
  223. package/dist/web/assets/init-app-B6mIRN6f.js +0 -1
  224. package/dist/web/assets/pinia-Cp_DCu4m.js +0 -1
  225. package/dist/web/assets/pinia-DCsnCHsH.js +0 -1
  226. package/dist/web/assets/railroad-abnf-HS5TGJTU-D6lX11kj.js +0 -1
  227. package/dist/web/assets/railroad-ebnf-LZEXJU2U-D3N0Z6uV.js +0 -1
  228. package/dist/web/assets/railroad-peg-WCYAUIDC-DOaenw8H.js +0 -1
  229. package/dist/web/assets/runtime-core.esm-bundler-BKFI1w28.js +0 -1
  230. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-B5SL6iu0.js +0 -8
  231. package/dist/web/assets/theme-C1B2V2RV.js +0 -1
  232. package/dist/web/assets/use-add-agent-context-3-KB2D10.js +0 -1
  233. package/dist/web/assets/use-app-theme-D_HR0B3W.js +0 -1
  234. package/dist/web/assets/use-panel-width-DIzwb-yt.js +0 -1
  235. package/dist/web/assets/use-router-link-BGc0noIO.js +0 -1
  236. package/dist/web/assets/use-server-events-C13Sw-rU.js +0 -1
  237. package/dist/web/assets/window-store-CnPjWx6-.js +0 -1
package/README.md CHANGED
@@ -61,14 +61,56 @@ node dist/cli/index.js serve
61
61
  `git rev-parse --show-toplevel` от текущей директории).
62
62
 
63
63
  ```bash
64
- node dist/cli/index.js serve --port 4000 --host 0.0.0.0 # LAN-доступ, см. предупреждение ниже
64
+ node dist/cli/index.js serve --port 4000 --host 0.0.0.0 # LAN-доступ, см. ниже
65
65
  node dist/cli/index.js list --status in_progress # список ревью без веб-интерфейса
66
66
  node dist/cli/index.js export last --format markdown -o review.md
67
67
  ```
68
68
 
69
- > **Осторожно с `--host 0.0.0.0`**: сервер по умолчанию не требует
70
- > аутентификации. Открывая его в локальной сети, любой в этой сети сможет
71
- > просматривать содержимое репозитория.
69
+ ### Браузерная совместимость
70
+
71
+ Production JS и CSS собираются с целью Chrome 103 и новее; для остальных
72
+ браузеров цели — Edge 111, Firefox 114, Safari/iOS 16.4. Мобильная компоновка,
73
+ Notes и терминал проверены в Chrome 103 на Android 13 в эмуляторе.
74
+ Цель сборки не добавляет полифилы всех браузерных API: отдельные возможности
75
+ (например, fullscreen) зависят от поддержки браузером. CSS Quasar и редактора
76
+ заметок (Milkdown) использует `:has()` и `dvh`; в Chrome до 105/108 эти правила
77
+ пропускаются, и меняется только оформление отдельных элементов.
78
+
79
+ ### Доступ из локальной сети
80
+
81
+ `--host` принимает `localhost` (по умолчанию), `127.0.0.1`, `::1`, а для
82
+ доступа из сети — `0.0.0.0` (IPv4) или `::` (IPv4 и IPv6). Конкретный
83
+ LAN-адрес не принимается: без loopback-слушателя некому подтвердить
84
+ устройство. При запуске на всех интерфейсах консоль печатает
85
+ `http://localhost:<порт>` и LAN-адреса машины.
86
+
87
+ Устройство из сети получает доступ только после сопряжения:
88
+
89
+ 1. Откройте LAN-адрес на телефоне или другой машине (адрес и QR-код —
90
+ в пункте меню **Devices** вкладки на `localhost`).
91
+ 2. Нажмите **Request access**: устройство покажет шестизначный код.
92
+ 3. Во вкладке на `localhost` появится запрос. Введите в нём код с экрана
93
+ устройства и нажмите **Approve**. Без открытой localhost-вкладки запрос
94
+ истекает через 2 минуты; консоль сервера печатает только факт запроса,
95
+ без кода.
96
+
97
+ Доверенное устройство работает со всем приложением, включая терминал и
98
+ агентов. Доступ хранится в cookie браузера и в
99
+ `.githuman/ght-reviews.db` (по базе на репозиторий) и истекает через 30 дней
100
+ без активности. Отзыв — в **Devices**: открытые терминал и чаты устройства
101
+ закрываются сразу (если тот же репозиторий открыт вторым сервером на другом порту — в течение 2 секунд). Управлять устройствами можно только с `localhost`.
102
+
103
+ Сервер принимает из сети только адреса своих интерфейсов в заголовке
104
+ `Host` — это защищает от DNS rebinding. Другое имя (например, имя машины в
105
+ Tailscale) разрешается флагом `--allowed-host <имя>`, его можно повторять.
106
+ Прокси на той же машине должен передавать `X-Forwarded-For`, иначе его
107
+ запросы выглядят локальными; `ssh -L` — локальный доступ самого пользователя.
108
+
109
+ > **Осторожно:** соединение идёт по обычному HTTP. Тот, кто слушает ту же
110
+ > сеть Wi-Fi, может перехватить cookie доверенного устройства и получить
111
+ > доступ к терминалу. Используйте LAN-доступ только в сети, которой доверяете.
112
+ > Cookie не разделяются по портам: другой веб-сервер на этой же машине тоже
113
+ > получит cookie устройства.
72
114
 
73
115
  ### Агенты
74
116
 
@@ -94,10 +136,9 @@ node dist/cli/index.js export last --format markdown -o review.md
94
136
  `{ "cursor": { "args": ["--model", "<модель>", "acp"] } }`. Допустимы поля
95
137
  `command`, `args`, `env`; любое другое (в том числе опечатка) — ошибка
96
138
  при запуске сервера.
97
- - **Только локально.** Агентские маршруты работают лишь при `--host`
98
- localhost/127.0.0.1/::1 (иначе это удалённое выполнение команд для всей
99
- сети) и отклоняют чужие `Origin`/`Host`. Открывайте приложение по
100
- `http://localhost:<порт>`.
139
+ - **Кто может запускать.** Агентские маршруты принимают запросы с этой
140
+ машины и с доверенных устройств (см. «Доступ из локальной сети») и
141
+ отклоняют чужие `Origin`/`Host`.
101
142
 
102
143
  ### Данные и совместимость с оригинальным mcollina/githuman
103
144
 
@@ -200,7 +241,7 @@ MIT, см. [LICENSE](LICENSE).
200
241
 
201
242
  Повтор команды текущей половины возвращает обычный размер и позицию. Перетаскивание заголовка из половины тоже возвращает обычный размер; ручной resize снимает режим половины. Сочетания работают при вводе в терминале и формах, но отключены в модальных диалогах. Win означает Super/Meta (на macOS — Command вместе с Ctrl). Системные сочетания ОС могут перехватываться до браузера.
202
243
 
203
- Если платформенный бинарник PTY недоступен, терминал работает в ограниченном режиме с баннером: команды вводятся в поле под выводом, Ctrl+C прерывает команду, Ctrl+D закрывает shell. `vim`, `less` и другие интерактивные программы требуют PTY. В LAN терминал отключён. Переключатель исходных цветов отключает минимальный контраст для программ со своей палитрой.
244
+ Если платформенный бинарник PTY недоступен, терминал работает в ограниченном режиме с баннером: команды вводятся в поле под выводом, Ctrl+C прерывает команду, Ctrl+D закрывает shell. `vim`, `less` и другие интерактивные программы требуют PTY. Из сети терминал доступен только доверенному устройству. Переключатель исходных цветов отключает минимальный контраст для программ со своей палитрой.
204
245
 
205
246
  Для разработки на другом порту backend можно передать `GITHUMAN_DEV_API=http://localhost:3848` при запуске Quasar dev-сервера.
206
247
 
@@ -1,6 +1,11 @@
1
+ import { isIP } from 'node:net';
1
2
  import { parseArgs } from 'node:util';
3
+ import { isLoopbackHost } from "../shared/network/loopback.js";
4
+ import { WILDCARD_HOSTS } from "../server/utils/lan-addresses.js";
2
5
  const DEFAULT_PORT = 3847;
3
6
  const DEFAULT_HOST = 'localhost';
7
+ // One DNS name: dot-separated labels of letters, digits and inner hyphens.
8
+ const HOST_NAME_PATTERN = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
4
9
  /**
5
10
  * Default prefix for filenames written under `.githuman/` (e.g.
6
11
  * `ght-reviews.db`). The original mcollina/githuman CLI reads and writes
@@ -42,8 +47,33 @@ function resolvePort(explicit) {
42
47
  }
43
48
  return parsed;
44
49
  }
50
+ /**
51
+ * Only loopback or every interface (ADR 0079). With one LAN address there
52
+ * is no loopback listener, so no local tab can approve a device.
53
+ */
45
54
  function resolveHost(explicit) {
46
- return explicit !== undefined && explicit.length > 0 ? explicit : DEFAULT_HOST;
55
+ if (explicit === undefined || explicit.length === 0) {
56
+ return DEFAULT_HOST;
57
+ }
58
+ if (WILDCARD_HOSTS.has(explicit) ||
59
+ (isLoopbackHost(explicit) && !explicit.startsWith('['))) {
60
+ return explicit;
61
+ }
62
+ throw new Error(`Invalid --host value "${explicit}": use localhost (default), or 0.0.0.0 or :: for access from the local network`);
63
+ }
64
+ /** Exact host names only: no scheme, port, path, userinfo or wildcard. */
65
+ function resolveAllowedHosts(values) {
66
+ return values.map(value => {
67
+ const name = String(value).toLowerCase();
68
+ // A loopback name allows nothing: local requests are found by the socket.
69
+ const loopback = name === 'localhost' ||
70
+ name.endsWith('.localhost') ||
71
+ name.startsWith('127.');
72
+ if (loopback || (!HOST_NAME_PATTERN.test(name) && isIP(name) !== 4)) {
73
+ throw new Error(`Invalid --allowed-host value "${String(value)}": give an exact non-loopback host name or IPv4 address, for example machine.tailnet.ts.net. IPv6 literals are not accepted.`);
74
+ }
75
+ return name;
76
+ });
47
77
  }
48
78
  export function parseServeArgs(argv) {
49
79
  const { values } = parseArgs({
@@ -55,7 +85,8 @@ export function parseServeArgs(argv) {
55
85
  'no-open': { type: 'boolean', default: false },
56
86
  'notes-dir': { type: 'string' },
57
87
  'db-prefix': { type: 'string' },
58
- 'api-docs': { type: 'boolean', default: false }
88
+ 'api-docs': { type: 'boolean', default: false },
89
+ 'allowed-host': { type: 'string', multiple: true }
59
90
  },
60
91
  strict: false
61
92
  });
@@ -64,12 +95,14 @@ export function parseServeArgs(argv) {
64
95
  const open = values['no-open'] === true ? false : values.open !== false;
65
96
  const dbPrefix = resolveDbPrefix(typeof values['db-prefix'] === 'string' ? values['db-prefix'] : undefined);
66
97
  const apiDocs = values['api-docs'] === true;
98
+ const allowedHosts = resolveAllowedHosts(Array.isArray(values['allowed-host']) ? values['allowed-host'] : []);
67
99
  return {
68
100
  port,
69
101
  host,
70
102
  open,
71
103
  dbPrefix,
72
104
  apiDocs,
105
+ allowedHosts,
73
106
  ...(typeof values['notes-dir'] === 'string'
74
107
  ? { notesDir: values['notes-dir'] }
75
108
  : process.env.GITHUMAN_NOTES_DIR
package/dist/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { writeFileSync } from 'node:fs';
4
4
  import { parseServeArgs } from "./config.js";
5
5
  import { startServer, resolveRepositoryPath, resolveReviewsDbPath } from "./server-runtime.js";
6
6
  import { formatStartupMessage } from "./startup-message.js";
7
+ import { lanAddresses } from "../server/utils/lan-addresses.js";
7
8
  import { parseListArgs, runList } from "./commands/list.js";
8
9
  import { parseExportArgs, runExport } from "./commands/export.js";
9
10
  import { createFileDatabase } from "../server/db/index.js";
@@ -47,7 +48,7 @@ async function main(argv) {
47
48
  };
48
49
  process.once('SIGINT', shutdown);
49
50
  process.once('SIGTERM', shutdown);
50
- console.log(formatStartupMessage(url, options.host));
51
+ console.log(formatStartupMessage(url, lanAddresses(options.host)));
51
52
  return;
52
53
  }
53
54
  case 'list': {
@@ -6,8 +6,8 @@ import { fileURLToPath } from 'node:url';
6
6
  import { buildApp } from "../server/app.js";
7
7
  import { createFileDatabase } from "../server/db/index.js";
8
8
  import { resolveDbPrefix } from "./config.js";
9
- import { isLoopbackHost } from "../shared/network/loopback.js";
10
9
  import { loadAgentPresets } from "../server/config/agents.js";
10
+ import { WILDCARD_HOSTS } from "../server/utils/lan-addresses.js";
11
11
  const execFileAsync = promisify(execFile);
12
12
  function resolveStaticRoot() {
13
13
  // Compiled layout: dist/cli/server-runtime.js sits next to dist/web/.
@@ -58,6 +58,8 @@ export function resolveAgentsConfigPath(repositoryPath, dbPrefix) {
58
58
  export async function startServer(options) {
59
59
  const repositoryPath = await resolveRepositoryPath(process.cwd());
60
60
  const db = createFileDatabase(resolveReviewsDbPath(repositoryPath, options.dbPrefix));
61
+ // Agents and the terminal exist on every bind. Their hooks decide per
62
+ // request: a request from this machine or a trusted device (ADR 0079).
61
63
  const app = buildApp({
62
64
  staticRoot: resolveStaticRoot(),
63
65
  repositoryPath,
@@ -66,18 +68,19 @@ export async function startServer(options) {
66
68
  db,
67
69
  watchFiles: true,
68
70
  apiDocs: options.apiDocs === true,
69
- loopbackBind: isLoopbackHost(options.host),
70
- terminalEnabled: isLoopbackHost(options.host),
71
- // Agents can run commands, so they are only offered on a loopback bind.
72
- ...(isLoopbackHost(options.host)
73
- ? {
74
- agentPresets: loadAgentPresets(resolveAgentsConfigPath(repositoryPath, options.dbPrefix))
75
- }
76
- : {})
71
+ allowedHosts: options.allowedHosts ?? [],
72
+ onPairingRequest: device => console.log(`Pairing request from ${device.name} (${device.address}). Enter the code from the device in a localhost tab.`),
73
+ terminalEnabled: true,
74
+ agentPresets: loadAgentPresets(resolveAgentsConfigPath(repositoryPath, options.dbPrefix))
77
75
  });
78
76
  const address = await app.listen({ port: options.port, host: options.host });
77
+ // A wildcard bind reports its own address (`http://[::]:3847`), which is
78
+ // not a local Host. The operator tab must use localhost to approve devices.
79
+ const url = WILDCARD_HOSTS.has(options.host)
80
+ ? `http://localhost:${new URL(address).port}`
81
+ : address;
79
82
  if (options.open) {
80
- openBrowser(address);
83
+ openBrowser(url);
81
84
  }
82
- return { app, url: address };
85
+ return { app, url };
83
86
  }
@@ -1,8 +1,9 @@
1
- import { isLoopbackHost } from "../shared/network/loopback.js";
2
- export function formatStartupMessage(url, host) {
1
+ import { addressUrl } from "../shared/network/address-url.js";
2
+ export function formatStartupMessage(url, lanAddresses) {
3
3
  const lines = [`githuman listening on ${url}`];
4
- if (!isLoopbackHost(host)) {
5
- lines.push('Warning: server is reachable from your local network without authentication.', 'Agent chat is disabled: it is only available when listening on localhost.');
4
+ if (lanAddresses.length > 0) {
5
+ const port = Number(new URL(url).port);
6
+ lines.push(...lanAddresses.map(address => `Local network: ${addressUrl(address, port)}`), 'Other devices request access at a local network address. Approve them in a localhost tab.', 'Warning: traffic is plain HTTP. Anyone on this network can read it, including device cookies.');
6
7
  }
7
8
  return lines.join('\n');
8
9
  }
@@ -33,8 +33,21 @@ import { createSessionRegistry } from "./services/agent/session-registry.js";
33
33
  import { createTestDatabase } from "./db/index.js";
34
34
  import { createEventBus } from "./event-bus.js";
35
35
  import { watchRepository } from "./services/file-watcher.service.js";
36
+ import fastifyCookie from '@fastify/cookie';
37
+ import fastifyPlugin from 'fastify-plugin';
38
+ import { DeviceService } from "./services/device.service.js";
39
+ import { DeviceConnections } from "./services/device-connections.js";
40
+ import { createAccessHook } from "./hooks/access/require-access.js";
41
+ import { deviceRoutes } from "./routes/devices.js";
42
+ import { AllowedHosts } from "./utils/allowed-hosts.js";
36
43
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- documented workaround above
37
44
  const fastifySse = fastifySseImport;
45
+ // Expired records leave the database within this time (ADR 0079, item 8).
46
+ const DEVICE_SWEEP_MS = 60 * 60 * 1000;
47
+ // Open connections of a device that lost access close within this time,
48
+ // also when another server process on the same database revoked it.
49
+ // One indexed SELECT per connected device.
50
+ const DEVICE_RECHECK_MS = 2000;
38
51
  export function buildApp(options = {}) {
39
52
  const app = Fastify({
40
53
  logger: false,
@@ -51,12 +64,36 @@ export function buildApp(options = {}) {
51
64
  const repositoryPath = options.repositoryPath ?? process.cwd();
52
65
  const db = options.db ?? createTestDatabase();
53
66
  const eventBus = options.eventBus ?? createEventBus();
67
+ const devices = new DeviceService(db, {
68
+ ...(options.now ? { now: options.now } : {}),
69
+ onChange: () => eventBus.publish({ type: 'devices:changed' })
70
+ });
71
+ const deviceConnections = new DeviceConnections(devices);
72
+ devices.removeExpired();
73
+ const sweep = setInterval(() => devices.removeExpired(), DEVICE_SWEEP_MS);
74
+ sweep.unref();
75
+ const recheck = setInterval(() => deviceConnections.recheck(), DEVICE_RECHECK_MS);
76
+ recheck.unref();
77
+ app.addHook('onClose', async () => {
78
+ clearInterval(sweep);
79
+ clearInterval(recheck);
80
+ });
54
81
  // Windows includes a PTY binary, but the shell and process list are Unix-only.
55
82
  const terminalEnabled = options.terminalEnabled === true && terminalSupported();
56
83
  if (terminalEnabled) {
57
84
  app.register(fastifyWebsocket, TERMINAL_WEBSOCKET_OPTIONS);
58
85
  }
59
86
  app.register(fastifySse);
87
+ app.decorateRequest('access', null);
88
+ app.register(fastifyCookie);
89
+ // Registered after the cookie and WebSocket plugins: the hook needs parsed
90
+ // cookies, and a rejected upgrade before the WebSocket hook leaves the socket open.
91
+ app.register(fastifyPlugin(async (root) => {
92
+ root.addHook('onRequest', createAccessHook({
93
+ devices,
94
+ allowedHosts: new AllowedHosts(options.allowedHosts)
95
+ }));
96
+ }));
60
97
  // The spec collects routes as they are added, so it comes before them.
61
98
  app.register(fastifySwagger, openApiOptions());
62
99
  if (options.apiDocs) {
@@ -72,10 +109,7 @@ export function buildApp(options = {}) {
72
109
  });
73
110
  const projectPath = options.projectPath ?? repositoryPath;
74
111
  const notes = new NotesService(projectPath, resolve(projectPath, options.notesDir ?? '.githuman/notes'), options.notesDir === undefined, eventBus, db);
75
- app.register(notesRoutes, {
76
- service: notes,
77
- loopbackBind: options.loopbackBind === true
78
- });
112
+ app.register(notesRoutes, { service: notes });
79
113
  app.addHook('onClose', async () => {
80
114
  await notes.identity.close();
81
115
  });
@@ -90,12 +124,20 @@ export function buildApp(options = {}) {
90
124
  app.register(reviewRoutes, { repositoryPath, db, eventBus });
91
125
  app.register(commentRoutes, { db, eventBus });
92
126
  app.register(exportRoutes, { db, repositoryPath });
127
+ app.register(deviceRoutes, {
128
+ devices,
129
+ ...(options.onPairingRequest
130
+ ? { onPairingRequest: options.onPairingRequest }
131
+ : {})
132
+ });
93
133
  app.register(eventRoutes, {
94
134
  eventBus,
135
+ connections: deviceConnections,
95
136
  instanceId: options.instanceId ?? randomUUID()
96
137
  });
97
138
  if (terminalEnabled) {
98
139
  app.register(terminalRoutes, {
140
+ deviceConnections,
99
141
  registry: options.terminalRegistry ??
100
142
  new TerminalRegistry({
101
143
  repositoryPath,
@@ -112,7 +154,8 @@ export function buildApp(options = {}) {
112
154
  }),
113
155
  db,
114
156
  repositoryPath,
115
- eventBus
157
+ eventBus,
158
+ connections: deviceConnections
116
159
  });
117
160
  }
118
161
  if (options.watchFiles) {
@@ -87,6 +87,25 @@ export function applyMigrations(db) {
87
87
  ON review_files(file_path);
88
88
  CREATE INDEX IF NOT EXISTS review_files_review_id_idx
89
89
  ON review_files(review_id);
90
+
91
+ -- One row: the random id in the device cookie name (ADR 0079).
92
+ CREATE TABLE IF NOT EXISTS server_identity (
93
+ id TEXT NOT NULL
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS devices (
97
+ id TEXT PRIMARY KEY,
98
+ name TEXT NOT NULL,
99
+ secret_hash TEXT NOT NULL UNIQUE,
100
+ state TEXT NOT NULL,
101
+ code TEXT NOT NULL,
102
+ host TEXT NOT NULL,
103
+ address TEXT NOT NULL,
104
+ created_at TEXT NOT NULL,
105
+ expires_at TEXT NOT NULL,
106
+ last_seen_at TEXT NOT NULL
107
+ );
108
+ CREATE INDEX IF NOT EXISTS devices_address_idx ON devices(address);
90
109
  `);
91
110
  const noteColumns = db
92
111
  .prepare('PRAGMA table_info(note_catalog)')
@@ -0,0 +1,7 @@
1
+ import createError from '@fastify/error';
2
+ export const DeviceNotFoundError = createError('GHT_DEVICE_NOT_FOUND', '%s', 404);
3
+ /** The pairing request exists but its time is over. */
4
+ export const PairingExpiredError = createError('GHT_PAIRING_EXPIRED', '%s', 410);
5
+ /** The code does not match, or the device is already trusted. */
6
+ export const PairingConflictError = createError('GHT_PAIRING_CONFLICT', '%s', 409);
7
+ export const PairingRateLimitError = createError('GHT_PAIRING_RATE_LIMIT', '%s', 429);
@@ -2,3 +2,4 @@ import createError from '@fastify/error';
2
2
  export const BadRequestError = createError('GHT_HTTP_BAD_REQUEST', '%s', 400);
3
3
  export const NotFoundError = createError('GHT_HTTP_NOT_FOUND', '%s', 404);
4
4
  export const ForbiddenError = createError('GHT_HTTP_FORBIDDEN', '%s', 403);
5
+ export const UnauthorizedError = createError('GHT_HTTP_UNAUTHORIZED', '%s', 401);
@@ -0,0 +1,21 @@
1
+ import { isLoopbackHost } from "../../../shared/network/loopback.js";
2
+ import { forwardedAddresses, isLoopbackAddress, parseHost, sameOrigin } from "../../utils/request-origin.js";
3
+ /**
4
+ * True when the request comes from a page or a client on this machine
5
+ * (ADR 0079). The `Host` header alone is not proof: with a bind on
6
+ * `0.0.0.0` any client sends `Host: localhost`. A proxy on this machine
7
+ * (the Quasar dev server, Tailscale Serve) connects from loopback, so a
8
+ * non-loopback `X-Forwarded-For` entry also makes the request remote.
9
+ * The loopback `Host` stays as the defence against DNS rebinding.
10
+ */
11
+ export function isLocalRequest(request) {
12
+ const { host, origin } = request.headers;
13
+ const forwarded = forwardedAddresses(request.headers['x-forwarded-for']);
14
+ const target = parseHost(host);
15
+ return (isLoopbackAddress(request.socket.remoteAddress) &&
16
+ forwarded !== undefined &&
17
+ forwarded.every(isLoopbackAddress) &&
18
+ target !== undefined &&
19
+ isLoopbackHost(target.hostname) &&
20
+ (origin === undefined || sameOrigin(origin, host)));
21
+ }
@@ -0,0 +1,93 @@
1
+ import { DEVICE_IDLE_TTL_MS } from "../../../shared/devices/constants.js";
2
+ import { ForbiddenError, UnauthorizedError } from "../../errors/http.js";
3
+ import { clientAddress, sameOrigin } from "../../utils/request-origin.js";
4
+ import { isLocalRequest } from "./local-request.js";
5
+ // Methods that do not change state; CSRF checks the others.
6
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
7
+ // API routes that a browser without access needs to request pairing.
8
+ const PUBLIC_ROUTES = new Set([
9
+ 'GET /api/access',
10
+ 'HEAD /api/access',
11
+ 'POST /api/devices/requests'
12
+ ]);
13
+ export function deviceCookieName(serverId) {
14
+ return `ght_device_${serverId}`;
15
+ }
16
+ /**
17
+ * The cookie lives 30 days from the request or the last renewal. The
18
+ * database decides if the secret still gives access (ADR 0079, item 8).
19
+ * There is no `Secure` flag: the server speaks plain HTTP.
20
+ */
21
+ export function setDeviceCookie(reply, serverId, secret) {
22
+ reply.setCookie(deviceCookieName(serverId), secret, {
23
+ httpOnly: true,
24
+ sameSite: 'strict',
25
+ path: '/',
26
+ maxAge: DEVICE_IDLE_TTL_MS / 1000
27
+ });
28
+ }
29
+ export function clearDeviceCookie(reply, serverId) {
30
+ reply.clearCookie(deviceCookieName(serverId), { path: '/' });
31
+ }
32
+ export function deviceSecret(request, serverId) {
33
+ return request.cookies[deviceCookieName(serverId)];
34
+ }
35
+ /**
36
+ * Global `onRequest` hook (ADR 0079, item 9). It runs for every bind, and
37
+ * decides by the matched route, not by `request.url`: Fastify finds
38
+ * `/api/reviews` for `/%61pi/reviews`. Static files, `/health` and the SPA
39
+ * fallback carry no data and pass.
40
+ */
41
+ export function createAccessHook(options) {
42
+ const { devices, allowedHosts } = options;
43
+ return async function requireAccess(request, reply) {
44
+ const route = request.routeOptions.url;
45
+ const api = request.url.startsWith('/api/') || route?.startsWith('/api/') === true;
46
+ if (!api)
47
+ return;
48
+ reply.header('cache-control', 'no-store');
49
+ const { host, origin } = request.headers;
50
+ const local = isLocalRequest(request);
51
+ if (!local && !allowedHosts.allows(host)) {
52
+ throw new ForbiddenError(`Unknown Host "${host ?? '-'}". Open the app by the IP address of this machine, or start the server with --allowed-host.`);
53
+ }
54
+ if (origin !== undefined &&
55
+ !SAFE_METHODS.has(request.method) &&
56
+ !sameOrigin(origin, host)) {
57
+ throw new ForbiddenError('The request Origin does not match the server.');
58
+ }
59
+ if (local) {
60
+ request.access = { local: true, device: { status: 'none' } };
61
+ return;
62
+ }
63
+ const device = devices.access(deviceSecret(request, devices.serverId), clientAddress(request.socket.remoteAddress, request.headers['x-forwarded-for']));
64
+ request.access = { local: false, device };
65
+ if (device.status === 'device' && device.renewed) {
66
+ setDeviceCookie(reply, devices.serverId, deviceSecret(request, devices.serverId) ?? '');
67
+ }
68
+ if (route !== undefined &&
69
+ PUBLIC_ROUTES.has(`${request.method} ${route}`)) {
70
+ return;
71
+ }
72
+ if (device.status !== 'device') {
73
+ throw new UnauthorizedError('This device has no access. Open the app to request access.');
74
+ }
75
+ };
76
+ }
77
+ /**
78
+ * True for a request of a trusted device that the access hook let through.
79
+ * A browser sends no `Origin` on a same-origin GET or EventSource, so the
80
+ * check needs `Origin` only when `originRequired` is set.
81
+ */
82
+ export function isTrustedDeviceRequest(request, originRequired) {
83
+ if (request.access?.device.status !== 'device')
84
+ return false;
85
+ const { host, origin } = request.headers;
86
+ return origin === undefined ? !originRequired : sameOrigin(origin, host);
87
+ }
88
+ /** Route hook for device management: only this machine may approve or revoke. */
89
+ export async function requireLocalRequest(request) {
90
+ if (!isLocalRequest(request)) {
91
+ throw new ForbiddenError('Only this machine manages devices. Open the app via localhost.');
92
+ }
93
+ }
@@ -1,33 +1,16 @@
1
1
  import { ForbiddenError } from "../../errors/http.js";
2
- import { isLoopbackHost } from "../../../shared/network/loopback.js";
3
- function hostnameOf(hostWithPort) {
4
- // "[::1]:3847" → "[::1]"; "localhost:3847" → "localhost"
5
- return hostWithPort.startsWith('[')
6
- ? hostWithPort.slice(0, hostWithPort.indexOf(']') + 1)
7
- : (hostWithPort.split(':')[0] ?? '');
8
- }
2
+ import { isLocalRequest } from "../access/local-request.js";
3
+ import { isTrustedDeviceRequest } from "../access/require-access.js";
9
4
  /**
10
- * `onRequest` hook for the agent routes (ADR 0023). They are an RCE surface,
11
- * so besides only being registered on a loopback bind, each request must
12
- * prove it came from a page served by this very server: a loopback `Host`
13
- * (defeats DNS rebinding, where a hostile domain resolves to 127.0.0.1) and,
14
- * when the browser sends an `Origin`, one identical to that `Host`.
15
- * Non-browser clients (curl, tests) send no `Origin` and pass.
5
+ * `onRequest` hook for the agent routes (ADR 0023, ADR 0079). They are an
6
+ * RCE surface, so each request must come from this machine (see
7
+ * `isLocalRequest`) or from a trusted device. Non-browser clients (curl,
8
+ * tests) send no `Origin` and pass.
16
9
  */
17
10
  export async function requireLocalOrigin(request) {
18
- const host = request.headers.host ?? '';
19
- const origin = request.headers.origin;
20
- let ok = isLoopbackHost(hostnameOf(host));
21
- if (ok && origin !== undefined) {
22
- try {
23
- ok = new URL(origin).host === host;
24
- }
25
- catch {
26
- ok = false;
27
- }
28
- }
29
- if (ok) {
11
+ if (isLocalRequest(request) || isTrustedDeviceRequest(request, false)) {
30
12
  return;
31
13
  }
32
- throw new ForbiddenError(`Agent endpoints only accept requests from this local server (Host: ${host || '-'}, Origin: ${origin ?? '-'}). Open the app via localhost.`);
14
+ const { host, origin } = request.headers;
15
+ throw new ForbiddenError(`Agent endpoints only accept requests from this machine or a trusted device (Host: ${host || '-'}, Origin: ${origin ?? '-'}).`);
33
16
  }
@@ -1,37 +1,10 @@
1
- import { isLoopbackHost } from "../../../shared/network/loopback.js";
2
1
  import { ForbiddenError } from "../../errors/http.js";
3
- // Returns false on any malformed value: an invalid URL means an invalid origin.
4
- function isSameOrigin(host, origin) {
5
- if (!host)
6
- return false;
7
- try {
8
- return new URL(origin).host === new URL(`http://${host}`).host;
2
+ import { sameOrigin } from "../../utils/request-origin.js";
3
+ // The global access hook decides who may read and write notes (ADR 0079).
4
+ // Notes also reject a foreign Origin on reads, unlike other routes.
5
+ export async function requireNotesOrigin(request) {
6
+ const { host, origin } = request.headers;
7
+ if (origin && !sameOrigin(origin, host)) {
8
+ throw new ForbiddenError('Notes require the same origin as this server');
9
9
  }
10
- catch {
11
- return false;
12
- }
13
- }
14
- function isLoopbackRequestHost(host) {
15
- if (!host)
16
- return false;
17
- try {
18
- return isLoopbackHost(new URL(`http://${host}`).hostname);
19
- }
20
- catch {
21
- return false;
22
- }
23
- }
24
- // A loopback bind also needs a loopback Host. A rebound hostile domain sends
25
- // matching Origin and Host, so the match alone does not stop DNS rebinding.
26
- // ADR 0008 allows LAN access without a password, so other binds skip this check.
27
- export function createNotesOriginHook(options) {
28
- return async function requireNotesOrigin(request) {
29
- const { host, origin } = request.headers;
30
- if (options.loopbackBind && !isLoopbackRequestHost(host)) {
31
- throw new ForbiddenError('Notes only accept requests from this local server. Open the app via localhost.');
32
- }
33
- if (origin && !isSameOrigin(host, origin)) {
34
- throw new ForbiddenError('Notes require the same origin as this server');
35
- }
36
- };
37
10
  }
@@ -1,26 +1,12 @@
1
- import { isLoopbackHost } from "../../../shared/network/loopback.js";
2
1
  import { ForbiddenError } from "../../errors/http.js";
3
- // Returns false on any malformed value: an invalid URL means an invalid origin.
4
- function isTerminalOrigin(host, origin) {
5
- if (!host || !origin)
6
- return false;
7
- try {
8
- const source = new URL(origin);
9
- const target = new URL(`http://${host}`);
10
- return (isLoopbackHost(target.hostname) &&
11
- target.host === host &&
12
- source.host === target.host &&
13
- source.protocol === 'http:' &&
14
- source.origin === origin &&
15
- !target.username &&
16
- !target.password);
17
- }
18
- catch {
19
- return false;
20
- }
21
- }
2
+ import { isLocalRequest } from "../access/local-request.js";
3
+ import { isTrustedDeviceRequest } from "../access/require-access.js";
4
+ // A browser always sends `Origin` on a WebSocket upgrade and a POST.
5
+ // Without it the token could not be bound to a page (ADR 0037).
22
6
  export async function requireTerminalOrigin(request) {
23
- if (!isTerminalOrigin(request.headers.host, request.headers.origin)) {
24
- throw new ForbiddenError('Terminal endpoints only accept requests from this local server. Open the app via localhost.');
7
+ const allowed = request.headers.origin !== undefined &&
8
+ (isLocalRequest(request) || isTrustedDeviceRequest(request, true));
9
+ if (!allowed) {
10
+ throw new ForbiddenError('Terminal endpoints only accept requests from this machine or a trusted device.');
25
11
  }
26
12
  }