@tequila99/githuman 0.9.0 → 0.10.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 (187) hide show
  1. package/README.md +40 -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/utils/address-key.js +33 -0
  24. package/dist/server/utils/allowed-hosts.js +38 -0
  25. package/dist/server/utils/device-name.js +27 -0
  26. package/dist/server/utils/lan-addresses.js +35 -0
  27. package/dist/server/utils/request-origin.js +105 -0
  28. package/dist/shared/devices/constants.js +16 -0
  29. package/dist/shared/devices/name.js +12 -0
  30. package/dist/shared/devices/schemas.js +67 -0
  31. package/dist/shared/devices/types.js +1 -0
  32. package/dist/shared/events/types.js +2 -1
  33. package/dist/shared/network/address-url.js +4 -0
  34. package/dist/shared/utils/date.js +3 -0
  35. package/dist/web/assets/AccessPage-CAjilkAc.js +1 -0
  36. package/dist/web/assets/AccessPage-DntcW7f8.css +1 -0
  37. package/dist/web/assets/{ClosePopup-lpH_1Ju2.js → ClosePopup-p9VBZw3j.js} +1 -1
  38. package/dist/web/assets/{CreateButton-Dnpwobd9.js → CreateButton-B7-yiqQx.js} +2 -2
  39. package/dist/web/assets/CreateReviewFabButton-BJOAdPG1.js +1 -0
  40. package/dist/web/assets/IndexPage-B0itnrts.js +1 -0
  41. package/dist/web/assets/MainLayout-CP6Bz7DA.js +6 -0
  42. package/dist/web/assets/{MainLayout-DM30ynlR.css → MainLayout-vDaYlRjL.css} +1 -1
  43. package/dist/web/assets/{NoteVisualEditor-Bj13_oEl.js → NoteVisualEditor-oWUBSuDR.js} +2 -2
  44. package/dist/web/assets/NotesPage-Ou5XG64h.js +4 -0
  45. package/dist/web/assets/PanelResize-D1cRotAi.js +1 -0
  46. package/dist/web/assets/{PdfPreview-C7HomxEg.js → PdfPreview-DIgRK8Vh.js} +1 -1
  47. package/dist/web/assets/QExpansionItem-BouBPgdb.js +4 -0
  48. package/dist/web/assets/{QFab-C-YZWgmW.js → QFab-B5iz2kQR.js} +1 -1
  49. package/dist/web/assets/QForm-tsecXm87.js +1 -0
  50. package/dist/web/assets/QResizeObserver-gIKjGVQ7.js +1 -0
  51. package/dist/web/assets/{QScrollArea-BSSZoaV5.js → QScrollArea-BrOkfO1I.js} +1 -1
  52. package/dist/web/assets/{QSelect-2iOBkp1K.js → QSelect-B5EBNlJ4.js} +1 -1
  53. package/dist/web/assets/QSpace-CqqgY-zX.js +1 -0
  54. package/dist/web/assets/{QVirtualScroll-CZJMBpmR.js → QVirtualScroll-BoXSHM25.js} +1 -1
  55. package/dist/web/assets/ReviewDetailPage-C3NBR_xR.js +1 -0
  56. package/dist/web/assets/{ReviewsPage-RyST0FlH.js → ReviewsPage-Bkinx_Bm.js} +1 -1
  57. package/dist/web/assets/StatusSwitcher-8QUHj5Rq.js +1 -0
  58. package/dist/web/assets/{TerminalView-BAzJgUTq.js → TerminalView-elu1dIYU.js} +2 -2
  59. package/dist/web/assets/{abnfDiagram-O67JEVCF-BNZQ7duT.js → abnfDiagram-O67JEVCF-BMF1j0ZS.js} +1 -1
  60. package/dist/web/assets/access-store-Y2QO4AK-.js +1 -0
  61. package/dist/web/assets/{agent-store-DP6Vrw6L.js → agent-store-1i-UjEzZ.js} +46 -46
  62. package/dist/web/assets/architecture-7GRP2DOG-DcZ4-ijA.js +1 -0
  63. package/dist/web/assets/{architectureDiagram-NJMV4G6O-Bkj0kfFn.js → architectureDiagram-NJMV4G6O-Bz4u-gZC.js} +1 -1
  64. package/dist/web/assets/{blockDiagram-BEXU5L5S-CXO3zN2c.js → blockDiagram-BEXU5L5S-qQW37qVK.js} +1 -1
  65. package/dist/web/assets/{c4Diagram-YGBWAQC7-BHfMeZNf.js → c4Diagram-YGBWAQC7-BBwd76CZ.js} +1 -1
  66. package/dist/web/assets/channel-Cgf35HIt.js +1 -0
  67. package/dist/web/assets/{chunk-3FUC2YCW-z1WbH7Gi.js → chunk-3FUC2YCW-BnhnfUz2.js} +1 -1
  68. package/dist/web/assets/{chunk-5DYCD2WN-K9HPch97.js → chunk-5DYCD2WN-DdK6RzN0.js} +1 -1
  69. package/dist/web/assets/{chunk-742MDFTN-Bhjp9fC-.js → chunk-742MDFTN-e_JaY6d5.js} +1 -1
  70. package/dist/web/assets/{chunk-7INBJB4K-Do0EmU14.js → chunk-7INBJB4K-CQjEZWay.js} +1 -1
  71. package/dist/web/assets/{chunk-7M6MHVWA-veeEjsOh.js → chunk-7M6MHVWA-Dhx8Ycf8.js} +1 -1
  72. package/dist/web/assets/{chunk-7PRAP22T-CPqB5tCK.js → chunk-7PRAP22T-DDWxOzU1.js} +1 -1
  73. package/dist/web/assets/{chunk-GTNCS2PH-Civ1V52P.js → chunk-GTNCS2PH-B-ZscolZ.js} +1 -1
  74. package/dist/web/assets/{chunk-GWA4HPMP-C7LzAlyZ.js → chunk-GWA4HPMP-CboS0KEV.js} +1 -1
  75. package/dist/web/assets/{chunk-MBY4JIJT-DfmJy0o_.js → chunk-MBY4JIJT-CiI_FkjG.js} +1 -1
  76. package/dist/web/assets/{chunk-NETBCI7D-BZIKmMvL.js → chunk-NETBCI7D-D9ennL0_.js} +1 -1
  77. package/dist/web/assets/{chunk-O7XYJQB3-DcHeu6b7.js → chunk-O7XYJQB3-y25ZXdWk.js} +1 -1
  78. package/dist/web/assets/{chunk-UA2S7LBM-0SR2og6x.js → chunk-UA2S7LBM-BRHq7OMm.js} +1 -1
  79. package/dist/web/assets/{chunk-WEXAMYUT-CJMSoM-Z.js → chunk-WEXAMYUT-CQu7edQh.js} +1 -1
  80. package/dist/web/assets/{chunk-Z7XXMR3K-q6fce66x.js → chunk-Z7XXMR3K-B3dBNvo3.js} +1 -1
  81. package/dist/web/assets/{chunk-ZIGJFQKS-DsPzen4Y.js → chunk-ZIGJFQKS-CbyA33sL.js} +1 -1
  82. package/dist/web/assets/{classDiagram-v2-NBCMYWYE-Cpm_bvzb.js → classDiagram-v2-NBCMYWYE-CgFqOQ8k.js} +1 -1
  83. package/dist/web/assets/{cynefin-OW5HDTMX-ZTr5XZMW.js → cynefin-OW5HDTMX-DJ9N0QYa.js} +1 -1
  84. package/dist/web/assets/{cynefinDiagram-VND7K2PF-ByUmUx58.js → cynefinDiagram-VND7K2PF-DIWysrbV.js} +1 -1
  85. package/dist/web/assets/{dagre-6A5THRUB-mQOy0E3r.js → dagre-6A5THRUB-Dt2w_dKa.js} +1 -1
  86. package/dist/web/assets/devices-store-CtEl_8An.js +1 -0
  87. package/dist/web/assets/{diagram-22UHCM2B-BW0LPtyk.js → diagram-22UHCM2B-DYd638CD.js} +1 -1
  88. package/dist/web/assets/{diagram-3UASUU5V-jo148ec5.js → diagram-3UASUU5V-B0a-K-41.js} +1 -1
  89. package/dist/web/assets/{diagram-ATOU4E4O-CJt4F0i4.js → diagram-ATOU4E4O-oH2KoPKR.js} +1 -1
  90. package/dist/web/assets/{diagram-CDSNMT55-DEShJv56.js → diagram-CDSNMT55-B5DGBpGB.js} +1 -1
  91. package/dist/web/assets/{diagram-MLGK6HIB-DOFOADfs.js → diagram-MLGK6HIB-C-bsgsc0.js} +1 -1
  92. package/dist/web/assets/{diagram-MPIPVDR6-ux537BMl.js → diagram-MPIPVDR6-BrlKNYLc.js} +1 -1
  93. package/dist/web/assets/{dist-Bu9yRqTU.js → dist-B8irqXQ_.js} +1 -1
  94. package/dist/web/assets/{dist-CLv-grWO.js → dist-B9umI5Mp.js} +1 -1
  95. package/dist/web/assets/{dist-BbdrgL5V.js → dist-Bf4bAu7y.js} +1 -1
  96. package/dist/web/assets/{dist-BThMRW5U.js → dist-Bi2OWZ35.js} +1 -1
  97. package/dist/web/assets/{dist-CK1va5C_.js → dist-Bso6nKGK.js} +1 -1
  98. package/dist/web/assets/{dist-DL0dBIkt.js → dist-C-lnpukg.js} +1 -1
  99. package/dist/web/assets/{dist-D1EUrS6y.js → dist-CNHlSxJl.js} +1 -1
  100. package/dist/web/assets/{dist-Bo7anpPj.js → dist-CfIeEdHm.js} +1 -1
  101. package/dist/web/assets/{dist-Bvnostf5.js → dist-CxiJzNsC.js} +1 -1
  102. package/dist/web/assets/{dist-7xWvOKEv.js → dist-DMe9nqcB.js} +1 -1
  103. package/dist/web/assets/{dist-BWa3ecCV.js → dist-Dz9Y2yQi.js} +1 -1
  104. package/dist/web/assets/{ebnfDiagram-ZINNZB2B-DEybuZle.js → ebnfDiagram-ZINNZB2B-BEpTgssQ.js} +1 -1
  105. package/dist/web/assets/{elk-276RUBZZ-CZXZRpGb.js → elk-276RUBZZ-lzj6IMyM.js} +1 -1
  106. package/dist/web/assets/{erDiagram-OPXOYQCR-BgRqGAt5.js → erDiagram-OPXOYQCR-CHOMf2bq.js} +1 -1
  107. package/dist/web/assets/eventmodeling-NTZA5JFV-CyXHXiX_.js +1 -0
  108. package/dist/web/assets/flowDiagram-KWPJA3E3-C4espOge.js +1 -0
  109. package/dist/web/assets/{ganttDiagram-FUAMR5RP-CBJwX1gk.js → ganttDiagram-FUAMR5RP-TumKIsrB.js} +1 -1
  110. package/dist/web/assets/{gitGraph-4MIJSDKK-Dw-XA3G1.js → gitGraph-4MIJSDKK-H-1qnbZy.js} +1 -1
  111. package/dist/web/assets/{gitGraphDiagram-X574FWY7-BpGccoWp.js → gitGraphDiagram-X574FWY7-DRIXoFhM.js} +1 -1
  112. package/dist/web/assets/i18n-xttkoi4H.js +1 -0
  113. package/dist/web/assets/index-BQi8n0xV.js +2 -0
  114. package/dist/web/assets/{info-A6RAGUB7-Dj5RfJsg.js → info-A6RAGUB7-W0vgdn7h.js} +1 -1
  115. package/dist/web/assets/{infoDiagram-VRGFBTTK-DyVSH991.js → infoDiagram-VRGFBTTK-Dkb5juPW.js} +1 -1
  116. package/dist/web/assets/init-app-DlPQG9dK.js +1 -0
  117. package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-Cu0n2oqT.js → ishikawaDiagram-OU5B5YK6-BHH8nvlH.js} +1 -1
  118. package/dist/web/assets/{journeyDiagram-ZHPQQLJL-Bz1Ot6Zg.js → journeyDiagram-ZHPQQLJL-DDyceAFf.js} +1 -1
  119. package/dist/web/assets/{kanban-definition-PNTS6WVX-C42vuY7H.js → kanban-definition-PNTS6WVX-DKE8pxuA.js} +1 -1
  120. package/dist/web/assets/{line-C5WKurbS.js → line-Cw6FtNmb.js} +1 -1
  121. package/dist/web/assets/{markdown-DBTEhbhQ.js → markdown-CIA-pqWB.js} +2 -2
  122. package/dist/web/assets/{mermaid-parser.core-B7l8qh8B.js → mermaid-parser.core-CWHLi3M4.js} +3 -3
  123. package/dist/web/assets/{mermaid.core-Drs2mjGg.js → mermaid.core-DbTjxMTH.js} +4 -4
  124. package/dist/web/assets/{mindmap-definition-NLK3R4M7-BV1FNwCn.js → mindmap-definition-NLK3R4M7-B3MzGWwm.js} +1 -1
  125. package/dist/web/assets/{packet-AYTQ26CC-BPmUY1Ig.js → packet-AYTQ26CC-B5qqn_tj.js} +1 -1
  126. package/dist/web/assets/{pegDiagram-GJSIUBJH-D6NPxPdr.js → pegDiagram-GJSIUBJH-DtcoRpgi.js} +1 -1
  127. package/dist/web/assets/{pie-WAS4IAKB-CTp2jNAc.js → pie-WAS4IAKB-BP-vRsnM.js} +1 -1
  128. package/dist/web/assets/{pieDiagram-5QR66LMP-BVkABrg7.js → pieDiagram-5QR66LMP-CYy6L6IC.js} +1 -1
  129. package/dist/web/assets/pinia-CJGnEjj3.js +1 -0
  130. package/dist/web/assets/{quadrantDiagram-O4NWA36T-LZCjVd08.js → quadrantDiagram-O4NWA36T-Hi2aHxKi.js} +1 -1
  131. package/dist/web/assets/{radar-RG4KPBEZ-CCiUrmsg.js → radar-RG4KPBEZ-BumlhyD5.js} +1 -1
  132. package/dist/web/assets/{railroad-74A4TZTK-CZn8VJbK.js → railroad-74A4TZTK-CROMzUe6.js} +1 -1
  133. package/dist/web/assets/railroad-abnf-HS5TGJTU-C5G3FxcO.js +1 -0
  134. package/dist/web/assets/railroad-ebnf-LZEXJU2U-BrTMLAtv.js +1 -0
  135. package/dist/web/assets/railroad-peg-WCYAUIDC-CXVDExl0.js +1 -0
  136. package/dist/web/assets/{railroadDiagram-XR7U4H2S-nopy-mdw.js → railroadDiagram-XR7U4H2S-De5-oSrU.js} +1 -1
  137. package/dist/web/assets/{requirementDiagram-PLB6GJNP-BOeMzf-x.js → requirementDiagram-PLB6GJNP-CBqteg6M.js} +1 -1
  138. package/dist/web/assets/{safe-storage-aRoyeFxv.js → safe-storage-BACMX75F.js} +1 -1
  139. package/dist/web/assets/{sankeyDiagram-IPEJSGJF-Bbk0AQUe.js → sankeyDiagram-IPEJSGJF-DfKF1Ck-.js} +1 -1
  140. package/dist/web/assets/{sequenceDiagram-PO4LG4MO-ZpubjKFc.js → sequenceDiagram-PO4LG4MO-D-sVWt3S.js} +1 -1
  141. package/dist/web/assets/single-flight-DLCbzLI3.js +1 -0
  142. package/dist/web/assets/{stateDiagram-v2-GCMORJYK-DNjDh9yZ.js → stateDiagram-v2-GCMORJYK-DruoQvcn.js} +1 -1
  143. package/dist/web/assets/{swimlanes-2SLR337P-C-_UzZF_.js → swimlanes-2SLR337P-BxFZLbJA.js} +1 -1
  144. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-C3zKO6Gm.js +8 -0
  145. package/dist/web/assets/theme-B01ZDSW1.js +1 -0
  146. package/dist/web/assets/{timeline-definition-EJHVYXUP-BiaJyHdJ.js → timeline-definition-EJHVYXUP-BixIZZx_.js} +1 -1
  147. package/dist/web/assets/{treeView-Q6P3EWNA-CENjlerX.js → treeView-Q6P3EWNA-Nzi6VzwD.js} +1 -1
  148. package/dist/web/assets/{treemap-WGGIJYW6-CBpdf3L2.js → treemap-WGGIJYW6-DmAPjB44.js} +1 -1
  149. package/dist/web/assets/{use-add-agent-context-SApxn9fW.js → use-add-agent-context-BE_z8acF.js} +1 -1
  150. package/dist/web/assets/{use-app-theme-DP8Ymwwb.js → use-app-theme-C1KrIJLx.js} +1 -1
  151. package/dist/web/assets/use-countdown-2uTxlnFE.js +1 -0
  152. package/dist/web/assets/{use-panel-width-CTXD_csP.js → use-panel-width-DFOmK5Az.js} +1 -1
  153. package/dist/web/assets/use-server-events-BrBCbeHu.js +1 -0
  154. package/dist/web/assets/{usecaseDiagram-POWQR4AR-CcJ83_Ve.js → usecaseDiagram-POWQR4AR-BlNkvrZH.js} +1 -1
  155. package/dist/web/assets/{vennDiagram-UO4OBE2U-DBjzpbFr.js → vennDiagram-UO4OBE2U-BFjSeLM4.js} +1 -1
  156. package/dist/web/assets/{wardley-WFR3VGLG-DmlbUWUM.js → wardley-WFR3VGLG-DTs7R1tQ.js} +1 -1
  157. package/dist/web/assets/{wardleyDiagram-VNRHLVJA-CWSZCqf4.js → wardleyDiagram-VNRHLVJA-BNMmP3TU.js} +1 -1
  158. package/dist/web/assets/window-store-CIFm3O3S.js +1 -0
  159. package/dist/web/assets/{xychartDiagram-PMCCYNJV-CMnNSaZv.js → xychartDiagram-PMCCYNJV-Bi24Uotp.js} +1 -1
  160. package/dist/web/index.html +1 -1
  161. package/package.json +3 -1
  162. package/dist/web/assets/CreateReviewFabButton-KvKPEJzI.js +0 -1
  163. package/dist/web/assets/IndexPage-DE-IkAhK.js +0 -1
  164. package/dist/web/assets/MainLayout-wWA8ckBg.js +0 -6
  165. package/dist/web/assets/NotesPage-FidBg83M.js +0 -4
  166. package/dist/web/assets/PanelResize-DY0Nzmql.js +0 -1
  167. package/dist/web/assets/QForm-DpEhYVCa.js +0 -4
  168. package/dist/web/assets/QResizeObserver-DeRFR_WF.js +0 -1
  169. package/dist/web/assets/QSpace-B5NneHAd.js +0 -1
  170. package/dist/web/assets/ReviewDetailPage-BI1iAhSf.js +0 -1
  171. package/dist/web/assets/StatusSwitcher-DAeNzNP7.js +0 -1
  172. package/dist/web/assets/app-info-store-u6ZVFjv5.js +0 -1
  173. package/dist/web/assets/architecture-7GRP2DOG-BSdnjp9Z.js +0 -1
  174. package/dist/web/assets/channel-DmoOM6AB.js +0 -1
  175. package/dist/web/assets/eventmodeling-NTZA5JFV-ZuRq6Sae.js +0 -1
  176. package/dist/web/assets/flowDiagram-KWPJA3E3-DRyHAdLb.js +0 -1
  177. package/dist/web/assets/i18n-sfJvmpVb.js +0 -1
  178. package/dist/web/assets/index-B2CzafCr.js +0 -2
  179. package/dist/web/assets/init-app-DSS44aRI.js +0 -1
  180. package/dist/web/assets/pinia-BVWaJ8NX.js +0 -1
  181. package/dist/web/assets/railroad-abnf-HS5TGJTU-B-9tG9pW.js +0 -1
  182. package/dist/web/assets/railroad-ebnf-LZEXJU2U-D7lUyGOq.js +0 -1
  183. package/dist/web/assets/railroad-peg-WCYAUIDC-ja94t32K.js +0 -1
  184. package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CVSZNvS2.js +0 -8
  185. package/dist/web/assets/theme-CeQhGDjh.js +0 -1
  186. package/dist/web/assets/use-server-events-C13Sw-rU.js +0 -1
  187. package/dist/web/assets/window-store-DNnKUhxs.js +0 -1
@@ -0,0 +1,74 @@
1
+ function rowToRecord(row) {
2
+ return {
3
+ id: row.id,
4
+ name: row.name,
5
+ secretHash: row.secret_hash,
6
+ state: row.state,
7
+ code: row.code,
8
+ host: row.host,
9
+ address: row.address,
10
+ createdAt: row.created_at,
11
+ expiresAt: row.expires_at,
12
+ lastSeenAt: row.last_seen_at
13
+ };
14
+ }
15
+ /** Reads the server id, or stores the one from `create` on first use. */
16
+ export function ensureServerId(db, create) {
17
+ const row = db.prepare('SELECT id FROM server_identity LIMIT 1').get();
18
+ if (row)
19
+ return row.id;
20
+ const id = create();
21
+ db.prepare('INSERT INTO server_identity (id) VALUES (?)').run(id);
22
+ return id;
23
+ }
24
+ export function insertDevice(db, device) {
25
+ db.prepare(`INSERT INTO devices (id, name, secret_hash, state, code, host, address, created_at, expires_at, last_seen_at)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(device.id, device.name, device.secretHash, device.state, device.code, device.host, device.address, device.createdAt, device.expiresAt, device.lastSeenAt);
27
+ }
28
+ export function findDeviceById(db, id) {
29
+ const row = db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
30
+ return row ? rowToRecord(row) : undefined;
31
+ }
32
+ export function findDeviceBySecretHash(db, secretHash) {
33
+ const row = db
34
+ .prepare('SELECT * FROM devices WHERE secret_hash = ?')
35
+ .get(secretHash);
36
+ return row ? rowToRecord(row) : undefined;
37
+ }
38
+ export function listDevices(db) {
39
+ const rows = db
40
+ .prepare('SELECT * FROM devices ORDER BY created_at')
41
+ .all();
42
+ return rows.map(rowToRecord);
43
+ }
44
+ export function countPendingDevices(db) {
45
+ const row = db
46
+ .prepare("SELECT COUNT(*) AS count FROM devices WHERE state = 'pending'")
47
+ .get();
48
+ return row.count;
49
+ }
50
+ /** Ids of records whose time is over. ISO times in UTC compare as text. */
51
+ export function expiredDeviceIds(db, nowIso, state) {
52
+ const sql = state === undefined
53
+ ? 'SELECT id FROM devices WHERE expires_at <= ?'
54
+ : 'SELECT id FROM devices WHERE expires_at <= ? AND state = ?';
55
+ const params = state === undefined ? [nowIso] : [nowIso, state];
56
+ const rows = db.prepare(sql).all(...params);
57
+ return rows.map(row => row.id);
58
+ }
59
+ export function updateDevice(db, id, fields) {
60
+ const columns = {
61
+ name: fields.name,
62
+ state: fields.state,
63
+ address: fields.address,
64
+ expires_at: fields.expiresAt,
65
+ last_seen_at: fields.lastSeenAt
66
+ };
67
+ const entries = Object.entries(columns).filter((entry) => entry[1] !== undefined);
68
+ if (entries.length === 0)
69
+ return;
70
+ db.prepare(`UPDATE devices SET ${entries.map(([column]) => `${column} = ?`).join(', ')} WHERE id = ?`).run(...entries.map(([, value]) => value), id);
71
+ }
72
+ export function deleteDevice(db, id) {
73
+ return db.prepare('DELETE FROM devices WHERE id = ?').run(id).changes > 0;
74
+ }
@@ -21,7 +21,7 @@ const AGENT_ERROR_RESPONSES = {
21
21
  }
22
22
  };
23
23
  export async function agentRoutes(app, opts) {
24
- const { registry, db, repositoryPath, eventBus } = opts;
24
+ const { registry, db, repositoryPath, eventBus, connections } = opts;
25
25
  const typedApp = app.withTypeProvider();
26
26
  const fileIndex = createFileIndex(repositoryPath, eventBus);
27
27
  app.addHook('onRequest', requireLocalOrigin);
@@ -237,6 +237,11 @@ export async function agentRoutes(app, opts) {
237
237
  if (!reply.sse.isConnected) {
238
238
  return;
239
239
  }
240
+ // No `await` between this check of the device and the subscription.
241
+ const release = connections.track(request, () => reply.sse.close());
242
+ if (!release) {
243
+ return;
244
+ }
240
245
  const unsubscribe = registry.subscribe(message => {
241
246
  if (message.type === 'event') {
242
247
  safeSend(reply.sse, {
@@ -262,8 +267,12 @@ export async function agentRoutes(app, opts) {
262
267
  // have run then, so the listener is removed here.
263
268
  if (!reply.sse.isConnected) {
264
269
  unsubscribe();
270
+ release();
265
271
  return;
266
272
  }
267
- reply.sse.onClose(unsubscribe);
273
+ reply.sse.onClose(() => {
274
+ unsubscribe();
275
+ release();
276
+ });
268
277
  });
269
278
  }
@@ -0,0 +1,130 @@
1
+ import { BadRequestError } from "../errors/http.js";
2
+ import { clearDeviceCookie, deviceSecret, requireLocalRequest, setDeviceCookie } from "../hooks/access/require-access.js";
3
+ import { machineAddresses } from "../utils/lan-addresses.js";
4
+ import { clientAddress } from "../utils/request-origin.js";
5
+ import { AccessSchema, ApproveDeviceBody, CreatePairingRequestBody, DeviceListSchema, DeviceParams, DeviceSchema, LanAddressesSchema, RenameDeviceBody } from "../../shared/devices/schemas.js";
6
+ import { ERROR_RESPONSES, NoContentSchema } from "../../shared/http/schemas.js";
7
+ // OpenAPI group of these routes.
8
+ const TAGS = ['devices'];
9
+ // The secret hash and the pairing code never reach the operator's browser:
10
+ // the code is shown on the device only (ADR 0080).
11
+ function toDevice({ secretHash: _secretHash, code: _code, ...device }) {
12
+ return device;
13
+ }
14
+ function pendingAccess(device, now) {
15
+ return {
16
+ status: 'pending',
17
+ pairing: {
18
+ code: device.code,
19
+ expiresAt: device.expiresAt,
20
+ remainingMs: Math.max(0, Date.parse(device.expiresAt) - now)
21
+ }
22
+ };
23
+ }
24
+ export async function deviceRoutes(app, opts) {
25
+ const { devices, onPairingRequest } = opts;
26
+ const typedApp = app.withTypeProvider();
27
+ typedApp.get('/api/access', {
28
+ schema: {
29
+ tags: TAGS,
30
+ summary: 'Access of this browser',
31
+ response: { 200: AccessSchema, ...ERROR_RESPONSES }
32
+ }
33
+ }, async (request, reply) => {
34
+ const access = request.access;
35
+ if (access?.local)
36
+ return { status: 'local' };
37
+ const device = access?.device ?? { status: 'none' };
38
+ if (device.status === 'device')
39
+ return { status: 'device' };
40
+ if (device.status === 'pending') {
41
+ return pendingAccess(device.device, devices.now());
42
+ }
43
+ // A stale secret blocks nothing, but a new request should start clean.
44
+ if (deviceSecret(request, devices.serverId) !== undefined) {
45
+ clearDeviceCookie(reply, devices.serverId);
46
+ }
47
+ return { status: 'none' };
48
+ });
49
+ typedApp.post('/api/devices/requests', {
50
+ schema: {
51
+ tags: TAGS,
52
+ summary: 'Request access for this browser',
53
+ body: CreatePairingRequestBody,
54
+ response: { 200: AccessSchema, ...ERROR_RESPONSES }
55
+ }
56
+ }, async (request, reply) => {
57
+ if (request.access?.local) {
58
+ throw new BadRequestError('This machine needs no pairing.');
59
+ }
60
+ const { device, secret } = devices.createRequest({
61
+ secret: deviceSecret(request, devices.serverId),
62
+ ...(request.body.name === undefined ? {} : { name: request.body.name }),
63
+ ...(request.headers['user-agent'] === undefined
64
+ ? {}
65
+ : { userAgent: request.headers['user-agent'] }),
66
+ address: clientAddress(request.socket.remoteAddress, request.headers['x-forwarded-for']),
67
+ host: request.headers.host ?? ''
68
+ });
69
+ if (secret === undefined)
70
+ return { status: 'device' };
71
+ setDeviceCookie(reply, devices.serverId, secret);
72
+ onPairingRequest?.(toDevice(device));
73
+ return pendingAccess(device, devices.now());
74
+ });
75
+ // Device management: only this machine may see, approve or revoke devices.
76
+ app.register(async (local) => {
77
+ local.addHook('onRequest', requireLocalRequest);
78
+ const typedLocal = local.withTypeProvider();
79
+ typedLocal.get('/api/devices', {
80
+ schema: {
81
+ tags: TAGS,
82
+ summary: 'List pending and trusted devices',
83
+ response: { 200: DeviceListSchema, ...ERROR_RESPONSES }
84
+ }
85
+ }, async () => devices.list().map(toDevice));
86
+ typedLocal.post('/api/devices/:id/approve', {
87
+ schema: {
88
+ tags: TAGS,
89
+ summary: 'Approve a pairing request',
90
+ params: DeviceParams,
91
+ body: ApproveDeviceBody,
92
+ response: {
93
+ 200: { ...DeviceSchema, description: 'The trusted device.' },
94
+ ...ERROR_RESPONSES
95
+ }
96
+ }
97
+ }, async (request) => toDevice(devices.approve(request.params.id, request.body.code)));
98
+ typedLocal.patch('/api/devices/:id', {
99
+ schema: {
100
+ tags: TAGS,
101
+ summary: 'Rename a device',
102
+ params: DeviceParams,
103
+ body: RenameDeviceBody,
104
+ response: {
105
+ 200: { ...DeviceSchema, description: 'The renamed device.' },
106
+ ...ERROR_RESPONSES
107
+ }
108
+ }
109
+ }, async (request) => toDevice(devices.rename(request.params.id, request.body.name)));
110
+ typedLocal.delete('/api/devices/:id', {
111
+ schema: {
112
+ tags: TAGS,
113
+ summary: 'Reject a pairing request or revoke a device',
114
+ params: DeviceParams,
115
+ response: { 204: NoContentSchema, ...ERROR_RESPONSES }
116
+ }
117
+ }, async (request, reply) => {
118
+ devices.revoke(request.params.id);
119
+ reply.code(204);
120
+ return null;
121
+ });
122
+ typedLocal.get('/api/devices/lan-addresses', {
123
+ schema: {
124
+ tags: TAGS,
125
+ summary: 'LAN addresses of this machine for a QR code',
126
+ response: { 200: LanAddressesSchema, ...ERROR_RESPONSES }
127
+ }
128
+ }, async () => machineAddresses());
129
+ });
130
+ }
@@ -1,7 +1,7 @@
1
1
  import { safeSend } from "../utils/sse.js";
2
2
  import { EventStreamSchema } from "../../shared/events/schemas.js";
3
3
  export async function eventRoutes(app, opts) {
4
- const { eventBus, instanceId } = opts;
4
+ const { eventBus, instanceId, connections } = opts;
5
5
  const hello = { instanceId };
6
6
  app.get('/api/events', {
7
7
  sse: true,
@@ -22,6 +22,11 @@ export async function eventRoutes(app, opts) {
22
22
  if (!reply.sse.isConnected) {
23
23
  return;
24
24
  }
25
+ // No `await` between this check of the device and the subscription.
26
+ const release = connections.track(request, () => reply.sse.close());
27
+ if (!release) {
28
+ return;
29
+ }
25
30
  const unsubscribe = eventBus.subscribe(event => {
26
31
  safeSend(reply.sse, {
27
32
  event: event.type,
@@ -30,6 +35,7 @@ export async function eventRoutes(app, opts) {
30
35
  });
31
36
  reply.sse.onClose(() => {
32
37
  unsubscribe();
38
+ release();
33
39
  });
34
40
  });
35
41
  }
@@ -7,14 +7,14 @@ import { NoteRecordSchema, NoteIdQuery, NoteDescriptorSchema, NoteResolveBody, N
7
7
  import { ERROR_RESPONSES, NoContentSchema } from "../../shared/http/schemas.js";
8
8
  import { BadRequestError } from "../errors/http.js";
9
9
  import { withRequestSignal } from "../utils/request-abort.js";
10
- import { createNotesOriginHook } from "../hooks/notes/same-origin.js";
11
- export async function notesRoutes(app, { service, loopbackBind = false, externalAssets = new NotesExternalAssetsService() }) {
10
+ import { requireNotesOrigin } from "../hooks/notes/same-origin.js";
11
+ export async function notesRoutes(app, { service, externalAssets = new NotesExternalAssetsService() }) {
12
12
  await app.register(multipart, {
13
13
  attachFieldsToBody: true,
14
14
  limits: { fileSize: MAX_NOTE_ASSET_BYTES, files: 1, fields: 0, parts: 1 }
15
15
  });
16
16
  const typed = app.withTypeProvider();
17
- app.addHook('onRequest', createNotesOriginHook({ loopbackBind }));
17
+ app.addHook('onRequest', requireNotesOrigin);
18
18
  app.post('/api/notes/assets', {
19
19
  preValidation: async (request) => {
20
20
  if (!request.isMultipart()) {
@@ -49,9 +49,17 @@ export async function terminalRoutes(app, options) {
49
49
  throw new ForbiddenError('Terminal connection token is invalid or expired.');
50
50
  }
51
51
  }
52
- }, socket => {
52
+ }, (socket, request) => {
53
+ // No `await` between this check of the device and the attach.
54
+ const release = options.deviceConnections.track(request, () => socket.terminate());
55
+ if (!release) {
56
+ return;
57
+ }
53
58
  const connection = attachTerminalSocket(socket, options.registry, {
54
- onDisconnect: () => connections.delete(connection)
59
+ onDisconnect: () => {
60
+ connections.delete(connection);
61
+ release();
62
+ }
55
63
  });
56
64
  connections.add(connection);
57
65
  });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Open streams and sockets of trusted devices (ADR 0079, item 13). A revoke
3
+ * in this process closes the connections in the same synchronous step.
4
+ * A revoke by another process and an expiry close them at the next
5
+ * `recheck`.
6
+ *
7
+ * Between the access hook and the registration of a stream there are
8
+ * `await` steps, and the device can lose access in them. So `track` checks
9
+ * the device again and adds the connection without an `await` between the
10
+ * two: `node:sqlite` is synchronous, and JS runs one step at a time.
11
+ */
12
+ export class DeviceConnections {
13
+ byDevice = new Map();
14
+ devices;
15
+ constructor(devices) {
16
+ this.devices = devices;
17
+ devices.onRemoved(id => this.closeDevice(id));
18
+ }
19
+ /**
20
+ * Registers `close` for the device of this request. Returns the release
21
+ * function for a normal close. Returns undefined when the device has no
22
+ * access any more: then `close` was already called. A local request has
23
+ * no device, so its release function does nothing.
24
+ */
25
+ track(request, close) {
26
+ const access = request.access;
27
+ if (!access || access.local)
28
+ return () => { };
29
+ const id = access.device.status === 'device' ? access.device.device.id : undefined;
30
+ if (id === undefined || !this.devices.isTrusted(id)) {
31
+ close();
32
+ return undefined;
33
+ }
34
+ let closers = this.byDevice.get(id);
35
+ if (!closers) {
36
+ closers = new Set();
37
+ this.byDevice.set(id, closers);
38
+ }
39
+ closers.add(close);
40
+ return () => {
41
+ closers.delete(close);
42
+ if (closers.size === 0 && this.byDevice.get(id) === closers) {
43
+ this.byDevice.delete(id);
44
+ }
45
+ };
46
+ }
47
+ /**
48
+ * Closes the connections of devices that lost access without a call of
49
+ * the local service: another server process on the same database revoked
50
+ * them, or their time is over. The listener of the local service cannot
51
+ * see such a change.
52
+ */
53
+ recheck() {
54
+ // Deleting the current key during Map iteration is safe.
55
+ for (const id of this.byDevice.keys()) {
56
+ if (!this.devices.isTrusted(id))
57
+ this.closeDevice(id);
58
+ }
59
+ }
60
+ /** Closes every connection of the device. A repeated call does nothing. */
61
+ closeDevice(id) {
62
+ const closers = this.byDevice.get(id);
63
+ if (!closers)
64
+ return;
65
+ this.byDevice.delete(id);
66
+ for (const close of closers) {
67
+ try {
68
+ close();
69
+ }
70
+ catch {
71
+ // One broken connection must not keep the others open.
72
+ }
73
+ }
74
+ }
75
+ /** Number of open connections of the device; for tests. */
76
+ count(id) {
77
+ return this.byDevice.get(id)?.size ?? 0;
78
+ }
79
+ }
@@ -0,0 +1,222 @@
1
+ import { randomBytes, randomInt, randomUUID } from 'node:crypto';
2
+ import { DEVICE_IDLE_TTL_MS, DEVICE_TOUCH_INTERVAL_MS, MAX_PAIRING_REQUESTS_PER_MINUTE, MAX_PENDING_REQUESTS, PAIRING_CODE_LENGTH, PAIRING_REQUEST_INTERVAL_MS, PAIRING_REQUEST_TTL_MS } from "../../shared/devices/constants.js";
3
+ import { cleanDeviceName } from "../../shared/devices/name.js";
4
+ import { toIsoString } from "../../shared/utils/date.js";
5
+ import { DeviceNotFoundError, PairingConflictError, PairingExpiredError, PairingRateLimitError } from "../errors/devices.js";
6
+ import { BadRequestError } from "../errors/http.js";
7
+ import { countPendingDevices, deleteDevice, ensureServerId, expiredDeviceIds, findDeviceById, findDeviceBySecretHash, insertDevice, listDevices, updateDevice } from "../repositories/device.repo.js";
8
+ import { deviceNameFromUserAgent } from "../utils/device-name.js";
9
+ import { addressKey } from "../utils/address-key.js";
10
+ import { sha256Hex } from "../utils/hash.js";
11
+ // Random bytes in a device secret: 256 bits, 43 base64url characters.
12
+ const SECRET_BYTES = 32;
13
+ // A cookie value of another length is not a secret of this server.
14
+ const SECRET_LENGTH = 43;
15
+ // Window of the global request limit.
16
+ const RATE_WINDOW_MS = 60 * 1000;
17
+ /**
18
+ * Pairing requests and trusted devices (ADR 0079). The secret lives only in
19
+ * the browser cookie; the database keeps its SHA-256. A random 256-bit
20
+ * secret cannot be found from response times, so a lookup by the hash
21
+ * index is safe without a constant-time compare.
22
+ */
23
+ export class DeviceService {
24
+ /** Random id in the cookie name: instances for other repositories keep their own cookie. */
25
+ serverId;
26
+ /** Clock of the service; routes use it for the time left of a request. */
27
+ now;
28
+ onChange;
29
+ lastRequestAt = new Map();
30
+ recentRequests = [];
31
+ removeListeners = new Set();
32
+ db;
33
+ constructor(db, options = {}) {
34
+ this.db = db;
35
+ this.now = options.now ?? Date.now;
36
+ this.onChange = options.onChange ?? (() => { });
37
+ this.serverId = ensureServerId(db, () => randomBytes(6).toString('base64url'));
38
+ }
39
+ /** Calls `listener` with the id of each removed device. Returns the unsubscribe function. */
40
+ onRemoved(listener) {
41
+ this.removeListeners.add(listener);
42
+ return () => this.removeListeners.delete(listener);
43
+ }
44
+ /**
45
+ * Creates a pairing request. A browser that is already trusted gets its
46
+ * device back and no new request. A new request from the same address
47
+ * or with the same cookie replaces the old one.
48
+ */
49
+ createRequest(input) {
50
+ const current = this.access(input.secret, input.address);
51
+ if (current.status === 'device') {
52
+ return { device: current.device };
53
+ }
54
+ this.removeExpiredPending();
55
+ this.checkRate(addressKey(input.address));
56
+ if (current.status === 'pending') {
57
+ this.remove(current.device.id);
58
+ }
59
+ const key = addressKey(input.address);
60
+ for (const device of listDevices(this.db)) {
61
+ if (device.state === 'pending' && addressKey(device.address) === key) {
62
+ this.remove(device.id);
63
+ }
64
+ }
65
+ // A full list drops the oldest request: otherwise a few addresses could
66
+ // keep every slot and lock the real device out.
67
+ while (countPendingDevices(this.db) >= MAX_PENDING_REQUESTS) {
68
+ const oldest = listDevices(this.db).find(device => device.state === 'pending');
69
+ if (!oldest)
70
+ break;
71
+ this.remove(oldest.id);
72
+ }
73
+ const now = this.now();
74
+ this.lastRequestAt.set(key, now);
75
+ this.recentRequests.push(now);
76
+ const secret = randomBytes(SECRET_BYTES).toString('base64url');
77
+ const device = {
78
+ id: randomUUID(),
79
+ name: cleanDeviceName(input.name) || deviceNameFromUserAgent(input.userAgent),
80
+ secretHash: sha256Hex(secret),
81
+ state: 'pending',
82
+ code: randomInt(0, 10 ** PAIRING_CODE_LENGTH)
83
+ .toString()
84
+ .padStart(PAIRING_CODE_LENGTH, '0'),
85
+ host: input.host,
86
+ address: input.address,
87
+ createdAt: toIsoString(now),
88
+ expiresAt: toIsoString(now + PAIRING_REQUEST_TTL_MS),
89
+ lastSeenAt: toIsoString(now)
90
+ };
91
+ insertDevice(this.db, device);
92
+ this.onChange();
93
+ return { device, secret };
94
+ }
95
+ /**
96
+ * Access of the browser with this cookie secret. An expired record never
97
+ * gives access. A trusted device records activity at most once per
98
+ * `DEVICE_TOUCH_INTERVAL_MS`.
99
+ */
100
+ access(secret, address) {
101
+ if (secret?.length !== SECRET_LENGTH)
102
+ return { status: 'none' };
103
+ const device = findDeviceBySecretHash(this.db, sha256Hex(secret));
104
+ const now = this.now();
105
+ if (!device || Date.parse(device.expiresAt) <= now) {
106
+ return { status: 'none' };
107
+ }
108
+ if (device.state === 'pending') {
109
+ return { status: 'pending', device };
110
+ }
111
+ if (now - Date.parse(device.lastSeenAt) < DEVICE_TOUCH_INTERVAL_MS) {
112
+ return { status: 'device', device, renewed: false };
113
+ }
114
+ const fields = {
115
+ lastSeenAt: toIsoString(now),
116
+ expiresAt: toIsoString(now + DEVICE_IDLE_TTL_MS),
117
+ ...(address === undefined ? {} : { address })
118
+ };
119
+ updateDevice(this.db, device.id, fields);
120
+ return { status: 'device', device: { ...device, ...fields }, renewed: true };
121
+ }
122
+ /** True when the device exists, is trusted and its time is not over. */
123
+ isTrusted(id) {
124
+ const device = findDeviceById(this.db, id);
125
+ return (device?.state === 'trusted' && Date.parse(device.expiresAt) > this.now());
126
+ }
127
+ /**
128
+ * Makes a pending request trusted. The code proves that the operator saw
129
+ * this request: a replaced request has another id, and a matching id with
130
+ * another code is a conflict.
131
+ */
132
+ approve(id, code) {
133
+ const device = findDeviceById(this.db, id);
134
+ if (!device) {
135
+ throw new DeviceNotFoundError('The pairing request does not exist. It was replaced, rejected or removed.');
136
+ }
137
+ if (device.state === 'trusted') {
138
+ throw new PairingConflictError('The device is already trusted.');
139
+ }
140
+ const now = this.now();
141
+ if (Date.parse(device.expiresAt) <= now) {
142
+ throw new PairingExpiredError('The pairing request has expired.');
143
+ }
144
+ if (device.code !== code) {
145
+ throw new PairingConflictError('The code does not match the pairing request.');
146
+ }
147
+ const fields = {
148
+ state: 'trusted',
149
+ lastSeenAt: toIsoString(now),
150
+ expiresAt: toIsoString(now + DEVICE_IDLE_TTL_MS)
151
+ };
152
+ updateDevice(this.db, id, fields);
153
+ this.onChange();
154
+ return { ...device, ...fields };
155
+ }
156
+ rename(id, name) {
157
+ const device = findDeviceById(this.db, id);
158
+ if (!device) {
159
+ throw new DeviceNotFoundError('The device does not exist.');
160
+ }
161
+ const trimmed = cleanDeviceName(name);
162
+ if (!trimmed) {
163
+ throw new BadRequestError('The device name is empty.');
164
+ }
165
+ updateDevice(this.db, id, { name: trimmed });
166
+ this.onChange();
167
+ return { ...device, name: trimmed };
168
+ }
169
+ /** Rejects a request or revokes a device. */
170
+ revoke(id) {
171
+ if (!this.remove(id)) {
172
+ throw new DeviceNotFoundError('The device does not exist.');
173
+ }
174
+ }
175
+ /** Devices with time left. Expired pending requests are removed first. */
176
+ list() {
177
+ this.removeExpiredPending();
178
+ const now = this.now();
179
+ return listDevices(this.db).filter(device => Date.parse(device.expiresAt) > now);
180
+ }
181
+ /** Removes every expired record through the revoke path. Returns their ids. */
182
+ removeExpired() {
183
+ const ids = expiredDeviceIds(this.db, toIsoString(this.now()));
184
+ for (const id of ids)
185
+ this.remove(id);
186
+ return ids;
187
+ }
188
+ removeExpiredPending() {
189
+ for (const id of expiredDeviceIds(this.db, toIsoString(this.now()), 'pending')) {
190
+ this.remove(id);
191
+ }
192
+ }
193
+ /**
194
+ * Deletes the record and tells listeners synchronously, so that open
195
+ * connections close in the same step (ADR 0079, item 13). A repeated call
196
+ * returns false and changes nothing.
197
+ */
198
+ remove(id) {
199
+ if (!deleteDevice(this.db, id))
200
+ return false;
201
+ for (const listener of this.removeListeners)
202
+ listener(id);
203
+ this.onChange();
204
+ return true;
205
+ }
206
+ checkRate(key) {
207
+ const now = this.now();
208
+ const last = this.lastRequestAt.get(key);
209
+ if (last !== undefined && now - last < PAIRING_REQUEST_INTERVAL_MS) {
210
+ throw new PairingRateLimitError('Pairing requests from this address are too frequent. Wait a few seconds.');
211
+ }
212
+ this.recentRequests = this.recentRequests.filter(time => now - time < RATE_WINDOW_MS);
213
+ if (this.recentRequests.length >= MAX_PAIRING_REQUESTS_PER_MINUTE) {
214
+ throw new PairingRateLimitError('Too many pairing requests. Try again in a minute.');
215
+ }
216
+ for (const [storedKey, time] of this.lastRequestAt) {
217
+ if (now - time >= PAIRING_REQUEST_INTERVAL_MS) {
218
+ this.lastRequestAt.delete(storedKey);
219
+ }
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,33 @@
1
+ import { isIPv4, isIPv6 } from 'node:net';
2
+ // An IPv6 client owns a whole /64 and can take any address inside it.
3
+ const IPV6_PREFIX_GROUPS = 4;
4
+ const IPV4_MAPPED_PREFIX = '::ffff:';
5
+ /** Expands an IPv6 address into 8 groups. The address must be valid. */
6
+ function ipv6Groups(address) {
7
+ const [head = '', tail] = address.split('::');
8
+ const headGroups = head === '' ? [] : head.split(':');
9
+ if (tail === undefined)
10
+ return headGroups;
11
+ const tailGroups = tail === '' ? [] : tail.split(':');
12
+ const gap = 8 - headGroups.length - tailGroups.length;
13
+ return [...headGroups, ...Array(gap).fill('0'), ...tailGroups];
14
+ }
15
+ /**
16
+ * Key that limits pairing requests of one client. An IPv6 address maps to
17
+ * its /64 prefix, an IPv4-mapped address to the IPv4 address. Any other
18
+ * text stays as it is.
19
+ */
20
+ export function addressKey(address) {
21
+ const plain = address.split('%')[0] ?? address;
22
+ if (plain.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) {
23
+ const mapped = plain.slice(IPV4_MAPPED_PREFIX.length);
24
+ if (isIPv4(mapped))
25
+ return mapped;
26
+ }
27
+ if (!isIPv6(plain))
28
+ return address;
29
+ const groups = ipv6Groups(plain.toLowerCase())
30
+ .slice(0, IPV6_PREFIX_GROUPS)
31
+ .map(group => parseInt(group, 16).toString(16));
32
+ return `${groups.join(':')}::/64`;
33
+ }
@@ -0,0 +1,38 @@
1
+ import { machineAddresses } from "./lan-addresses.js";
2
+ import { parseHost } from "./request-origin.js";
3
+ // DHCP can change an address; a short cache keeps the lookup cheap per request.
4
+ const ADDRESS_CACHE_MS = 5000;
5
+ /**
6
+ * `Host` values that a LAN request may use (ADR 0079): addresses of this
7
+ * machine and the exact names from `--allowed-host`. A page of a rebound
8
+ * hostile domain sends its own name, so it cannot reach the API or start
9
+ * a pairing request.
10
+ */
11
+ export class AllowedHosts {
12
+ names;
13
+ addresses;
14
+ now;
15
+ cached;
16
+ constructor(names = [], addresses = machineAddresses, now = Date.now) {
17
+ this.names = new Set(names.map(name => name.toLowerCase()));
18
+ this.addresses = addresses;
19
+ this.now = now;
20
+ }
21
+ allows(host) {
22
+ const hostname = parseHost(host)?.hostname;
23
+ if (hostname === undefined)
24
+ return false;
25
+ const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
26
+ return this.names.has(bare) || this.machineHosts().has(bare);
27
+ }
28
+ machineHosts() {
29
+ const now = this.now();
30
+ if (!this.cached || now - this.cached.at >= ADDRESS_CACHE_MS) {
31
+ this.cached = {
32
+ at: now,
33
+ hosts: new Set(this.addresses().map(address => address.toLowerCase()))
34
+ };
35
+ }
36
+ return this.cached.hosts;
37
+ }
38
+ }