@homeflare/alchemy 0.38.0 → 0.40.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 (353) hide show
  1. package/README.md +32 -8
  2. package/dist/ceph/auth-entity.d.ts +34 -0
  3. package/dist/ceph/auth-entity.d.ts.map +1 -0
  4. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  5. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  6. package/dist/ceph/ceph-argv.d.ts +19 -0
  7. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  8. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  9. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  10. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  12. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  14. package/dist/ceph/ceph-config-options.d.ts +23 -0
  15. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  16. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  18. package/dist/ceph/ceph-transport.d.ts +37 -0
  19. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  20. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  22. package/dist/ceph/index.d.ts +13 -0
  23. package/dist/ceph/index.d.ts.map +1 -0
  24. package/dist/ceph/index.js +1609 -0
  25. package/dist/ceph/index.js.map +38 -0
  26. package/dist/forgejo/index.js +4 -2
  27. package/dist/forgejo/index.js.map +3 -3
  28. package/dist/forgejo/team-member.d.ts +25 -0
  29. package/dist/forgejo/team-member.d.ts.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  34. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  41. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  44. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  45. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  46. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  47. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  48. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  49. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  50. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  51. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  52. package/dist/proxmox/index.d.ts +14 -3
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +574 -133
  55. package/dist/proxmox/index.js.map +33 -23
  56. package/dist/proxmox/qemu-form.d.ts +27 -18
  57. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  58. package/dist/proxmox/qemu-judge.d.ts +30 -0
  59. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  60. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  61. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  62. package/dist/proxmox/qemu-net.d.ts +9 -0
  63. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  64. package/dist/proxmox/qemu-props.d.ts +112 -0
  65. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  66. package/dist/proxmox/qemu-volume.d.ts +27 -0
  67. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  68. package/dist/proxmox/qemu.d.ts +14 -27
  69. package/dist/proxmox/qemu.d.ts.map +1 -1
  70. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  71. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  72. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  73. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  74. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  75. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  76. package/dist/proxmox/storage-download-props.d.ts +36 -0
  77. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  78. package/dist/proxmox/storage-download-task.d.ts +17 -0
  79. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  80. package/dist/proxmox/storage-download.d.ts +44 -0
  81. package/dist/proxmox/storage-download.d.ts.map +1 -0
  82. package/dist/talos/credentials-write.d.ts +46 -0
  83. package/dist/talos/credentials-write.d.ts.map +1 -0
  84. package/dist/talos/credentials.d.ts +51 -9
  85. package/dist/talos/credentials.d.ts.map +1 -1
  86. package/dist/talos/fake-process.d.ts +56 -0
  87. package/dist/talos/fake-process.d.ts.map +1 -0
  88. package/dist/talos/index.d.ts +19 -4
  89. package/dist/talos/index.d.ts.map +1 -1
  90. package/dist/talos/index.js +357 -173
  91. package/dist/talos/index.js.map +16 -10
  92. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  93. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  94. package/dist/talos/kubeconfig.d.ts +41 -23
  95. package/dist/talos/kubeconfig.d.ts.map +1 -1
  96. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  97. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  98. package/dist/talos/machine-config-poll.d.ts +43 -0
  99. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  100. package/dist/talos/machine-config-read.d.ts +49 -0
  101. package/dist/talos/machine-config-read.d.ts.map +1 -0
  102. package/dist/talos/resource.d.ts +18 -0
  103. package/dist/talos/resource.d.ts.map +1 -1
  104. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  105. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  106. package/dist/talos/talos-bootstrap.d.ts +87 -0
  107. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  108. package/dist/talos/talos-cluster-health.d.ts +43 -1
  109. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  110. package/dist/talos/talos-errors.d.ts +68 -0
  111. package/dist/talos/talos-errors.d.ts.map +1 -0
  112. package/dist/talos/talos-machine-config.d.ts +51 -15
  113. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  114. package/dist/talos/talosctl.d.ts +7 -0
  115. package/dist/talos/talosctl.d.ts.map +1 -1
  116. package/dist/talos/values.d.ts +23 -3
  117. package/dist/talos/values.d.ts.map +1 -1
  118. package/dist/telemetry/fake-collector.d.ts +36 -0
  119. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  120. package/dist/telemetry/index.d.ts +11 -0
  121. package/dist/telemetry/index.d.ts.map +1 -0
  122. package/dist/telemetry/index.js +223 -0
  123. package/dist/telemetry/index.js.map +12 -0
  124. package/dist/telemetry/layer.d.ts +86 -0
  125. package/dist/telemetry/layer.d.ts.map +1 -0
  126. package/dist/telemetry/redact-serialization.d.ts +21 -0
  127. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  128. package/dist/telemetry/redact.d.ts +46 -0
  129. package/dist/telemetry/redact.d.ts.map +1 -0
  130. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  131. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  132. package/dist/unifi/acl-rule-form.d.ts +90 -0
  133. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  134. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  135. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  136. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  137. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  138. package/dist/unifi/acl-rule.d.ts +49 -0
  139. package/dist/unifi/acl-rule.d.ts.map +1 -0
  140. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  141. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  142. package/dist/unifi/dns-policy-form.d.ts +97 -0
  143. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  144. package/dist/unifi/dns-policy.d.ts +55 -0
  145. package/dist/unifi/dns-policy.d.ts.map +1 -0
  146. package/dist/unifi/drift.d.ts +27 -0
  147. package/dist/unifi/drift.d.ts.map +1 -0
  148. package/dist/unifi/fake-unifi.d.ts +5 -1
  149. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  150. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  151. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  152. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  153. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  154. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  155. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  156. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  157. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  158. package/dist/unifi/firewall-policy.d.ts +57 -0
  159. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  160. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  161. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  162. package/dist/unifi/firewall-zone.d.ts +1 -1
  163. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  164. package/dist/unifi/index.d.ts +11 -3
  165. package/dist/unifi/index.d.ts.map +1 -1
  166. package/dist/unifi/index.js +725 -60
  167. package/dist/unifi/index.js.map +31 -9
  168. package/dist/unifi/network-drift.d.ts +26 -0
  169. package/dist/unifi/network-drift.d.ts.map +1 -0
  170. package/dist/unifi/network-form.d.ts +40 -7
  171. package/dist/unifi/network-form.d.ts.map +1 -1
  172. package/dist/unifi/network.d.ts +1 -0
  173. package/dist/unifi/network.d.ts.map +1 -1
  174. package/dist/unifi/paginate.d.ts +24 -0
  175. package/dist/unifi/paginate.d.ts.map +1 -0
  176. package/dist/unifi/providers.d.ts +1 -1
  177. package/dist/unifi/providers.d.ts.map +1 -1
  178. package/dist/unifi/resource.d.ts +36 -1
  179. package/dist/unifi/resource.d.ts.map +1 -1
  180. package/dist/unifi/scan-source.d.ts +11 -0
  181. package/dist/unifi/scan-source.d.ts.map +1 -0
  182. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  183. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  184. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  185. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  186. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  187. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  188. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  189. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  190. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  191. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  192. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  193. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  194. package/docs/telemetry-spike.md +94 -0
  195. package/docs/telemetry.md +109 -0
  196. package/docs/unifi-acl-rule.md +122 -0
  197. package/docs/unifi-dns-policy.md +75 -0
  198. package/docs/unifi-firewall-policy.md +109 -0
  199. package/docs/unifi-traffic-matching-list.md +82 -0
  200. package/docs/unifi-wifi.md +120 -0
  201. package/docs/unifi.md +64 -3
  202. package/package.json +10 -2
  203. package/src/ceph/auth-entity.ts +74 -0
  204. package/src/ceph/ceph-argv-config.ts +78 -0
  205. package/src/ceph/ceph-argv.test.ts +226 -0
  206. package/src/ceph/ceph-argv.ts +189 -0
  207. package/src/ceph/ceph-auth-form.test.ts +103 -0
  208. package/src/ceph/ceph-auth-form.ts +107 -0
  209. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  210. package/src/ceph/ceph-auth-parse.ts +121 -0
  211. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  212. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  213. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  214. package/src/ceph/ceph-config-options.test.ts +44 -0
  215. package/src/ceph/ceph-config-options.ts +36 -0
  216. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  217. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  218. package/src/ceph/ceph-transport.test.ts +125 -0
  219. package/src/ceph/ceph-transport.ts +117 -0
  220. package/src/ceph/fake-ceph-dial.ts +55 -0
  221. package/src/ceph/index.ts +12 -0
  222. package/src/forgejo/team-member.test.ts +14 -0
  223. package/src/forgejo/team-member.ts +28 -1
  224. package/src/index.ts +1 -0
  225. package/src/proxmox/constraints-required.test.ts +1 -0
  226. package/src/proxmox/generated/constraints/index.ts +3 -1
  227. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  228. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  229. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  230. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  231. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  232. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  233. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  234. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  235. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  236. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  237. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  238. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  239. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  240. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  241. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  242. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  243. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  244. package/src/proxmox/index.test.ts +11 -6
  245. package/src/proxmox/index.ts +19 -3
  246. package/src/proxmox/qemu-form.ts +49 -13
  247. package/src/proxmox/qemu-identity.test.ts +30 -0
  248. package/src/proxmox/qemu-judge.test.ts +65 -0
  249. package/src/proxmox/qemu-judge.ts +84 -0
  250. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  251. package/src/proxmox/qemu-lifecycle.ts +131 -32
  252. package/src/proxmox/qemu-net.test.ts +48 -0
  253. package/src/proxmox/qemu-net.ts +78 -0
  254. package/src/proxmox/qemu-ownership.test.ts +44 -0
  255. package/src/proxmox/qemu-props.test.ts +87 -0
  256. package/src/proxmox/qemu-props.ts +157 -0
  257. package/src/proxmox/qemu-volume.test.ts +229 -0
  258. package/src/proxmox/qemu-volume.ts +178 -0
  259. package/src/proxmox/qemu.ts +15 -29
  260. package/src/proxmox/storage-download-errors.ts +7 -0
  261. package/src/proxmox/storage-download-identity.test.ts +54 -0
  262. package/src/proxmox/storage-download-identity.ts +50 -0
  263. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  264. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  265. package/src/proxmox/storage-download-props.ts +38 -0
  266. package/src/proxmox/storage-download-task.ts +72 -0
  267. package/src/proxmox/storage-download.test.ts +216 -0
  268. package/src/proxmox/storage-download.ts +58 -0
  269. package/src/talos/credentials-write.test.ts +81 -0
  270. package/src/talos/credentials-write.ts +95 -0
  271. package/src/talos/credentials.test.ts +157 -0
  272. package/src/talos/credentials.ts +160 -90
  273. package/src/talos/fake-process.ts +94 -0
  274. package/src/talos/index.ts +39 -4
  275. package/src/talos/kubeconfig-attrs.ts +55 -0
  276. package/src/talos/kubeconfig.test.ts +256 -0
  277. package/src/talos/kubeconfig.ts +170 -90
  278. package/src/talos/machine-config-fixtures.ts +86 -0
  279. package/src/talos/machine-config-poll.test.ts +57 -0
  280. package/src/talos/machine-config-poll.ts +82 -0
  281. package/src/talos/machine-config-read.ts +118 -0
  282. package/src/talos/resource.ts +19 -0
  283. package/src/talos/secrets-gate.test.ts +63 -0
  284. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  285. package/src/talos/talos-bootstrap.test.ts +218 -0
  286. package/src/talos/talos-bootstrap.ts +151 -45
  287. package/src/talos/talos-cluster-health.test.ts +143 -0
  288. package/src/talos/talos-cluster-health.ts +91 -28
  289. package/src/talos/talos-errors.ts +85 -0
  290. package/src/talos/talos-machine-config.test.ts +189 -0
  291. package/src/talos/talos-machine-config.ts +102 -88
  292. package/src/talos/talosctl.ts +8 -0
  293. package/src/talos/values.test.ts +34 -6
  294. package/src/talos/values.ts +41 -7
  295. package/src/telemetry/fake-collector.ts +59 -0
  296. package/src/telemetry/http-client-sealing.test.ts +82 -0
  297. package/src/telemetry/index.ts +10 -0
  298. package/src/telemetry/layer.test.ts +204 -0
  299. package/src/telemetry/layer.ts +152 -0
  300. package/src/telemetry/redact-serialization.ts +178 -0
  301. package/src/telemetry/redact.test.ts +124 -0
  302. package/src/telemetry/redact.ts +147 -0
  303. package/src/unifi/acl-rule-drift.ts +57 -0
  304. package/src/unifi/acl-rule-form.ts +135 -0
  305. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  306. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  307. package/src/unifi/acl-rule-ordering.ts +70 -0
  308. package/src/unifi/acl-rule.test.ts +183 -0
  309. package/src/unifi/acl-rule.ts +61 -0
  310. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  311. package/src/unifi/dns-policy-drift.ts +48 -0
  312. package/src/unifi/dns-policy-form.ts +142 -0
  313. package/src/unifi/dns-policy.test.ts +154 -0
  314. package/src/unifi/dns-policy.ts +67 -0
  315. package/src/unifi/drift.test.ts +78 -0
  316. package/src/unifi/drift.ts +56 -0
  317. package/src/unifi/errors-and-secrets.test.ts +127 -0
  318. package/src/unifi/fake-unifi.ts +5 -1
  319. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  320. package/src/unifi/firewall-policy-drift.ts +55 -0
  321. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  322. package/src/unifi/firewall-policy-form.ts +146 -0
  323. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  324. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  325. package/src/unifi/firewall-policy-ordering.ts +87 -0
  326. package/src/unifi/firewall-policy.test.ts +183 -0
  327. package/src/unifi/firewall-policy.ts +73 -0
  328. package/src/unifi/firewall-zone-form.ts +31 -2
  329. package/src/unifi/firewall-zone.test.ts +21 -1
  330. package/src/unifi/firewall-zone.ts +1 -1
  331. package/src/unifi/get-only-guard.test.ts +165 -0
  332. package/src/unifi/index.ts +78 -1
  333. package/src/unifi/network-drift.test.ts +207 -0
  334. package/src/unifi/network-drift.ts +80 -0
  335. package/src/unifi/network-form.ts +11 -39
  336. package/src/unifi/network.test.ts +7 -82
  337. package/src/unifi/network.ts +6 -6
  338. package/src/unifi/paginate.test.ts +252 -0
  339. package/src/unifi/paginate.ts +141 -0
  340. package/src/unifi/providers.ts +19 -1
  341. package/src/unifi/resource.ts +73 -3
  342. package/src/unifi/scan-source.ts +64 -0
  343. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  344. package/src/unifi/traffic-matching-list-form.ts +77 -0
  345. package/src/unifi/traffic-matching-list.test.ts +202 -0
  346. package/src/unifi/traffic-matching-list.ts +78 -0
  347. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  348. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  349. package/src/unifi/wifi-broadcast-form.ts +211 -0
  350. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  351. package/src/unifi/wifi-broadcast.test.ts +155 -0
  352. package/src/unifi/wifi-broadcast.ts +90 -0
  353. package/src/unifi/write-op-reference.test.ts +190 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `Unifi.FirewallPolicyOrdering`'s `spec` against a fake UniFi Network API — mirrors
3
+ * `acl-rule-ordering.test.ts`. The two things this file exists to prove that no other family test
4
+ * does: the zone-pair QUERY identity actually reaches the wire (unlike `acl-rule-ordering.ts`'s
5
+ * one-per-site path with no query at all), and T5's "order IS the value, never `sortedSet`" rule
6
+ * holds independently for BOTH `beforeSystemDefined` and `afterSystemDefined`.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
10
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
11
+ import * as Effect from 'effect/Effect';
12
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
13
+ import { attributesOf, driftOf, matches } from './firewall-policy-ordering-form.ts';
14
+ import {
15
+ type FirewallPolicyOrderingProps,
16
+ declareFirewallPolicyOrdering,
17
+ spec,
18
+ } from './firewall-policy-ordering.ts';
19
+ import { unifiOperations } from './resource.ts';
20
+
21
+ const ORDERING_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies/ordering';
22
+
23
+ const liveOrdering = (
24
+ overrides: Partial<firewall.IntegrationFirewallPolicyOrderingDto> = {},
25
+ ): firewall.IntegrationFirewallPolicyOrderingDto => ({
26
+ orderedFirewallPolicyIds: {
27
+ beforeSystemDefined: ['fp-1', 'fp-2'],
28
+ afterSystemDefined: ['fp-9'],
29
+ },
30
+ ...overrides,
31
+ });
32
+
33
+ const PROPS: FirewallPolicyOrderingProps = {
34
+ siteId: 'site-1',
35
+ sourceFirewallZoneId: 'zone-iot',
36
+ destinationFirewallZoneId: 'zone-external',
37
+ beforeSystemDefined: ['fp-1', 'fp-2'],
38
+ afterSystemDefined: ['fp-9'],
39
+ };
40
+
41
+ describe('Unifi.FirewallPolicyOrdering spec.fetchLive', () => {
42
+ test('a real GET response decodes into the typed live object, with the zone pair as a query', async () => {
43
+ const fake = fakeUnifi((method, url) =>
44
+ method === 'GET' && url.pathname === ORDERING_PATH
45
+ ? Response.json(liveOrdering())
46
+ : fakeFailure(400, 'unexpected request'),
47
+ );
48
+ const live = await Effect.runPromise(
49
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
50
+ );
51
+ expect(live?.orderedFirewallPolicyIds.beforeSystemDefined).toEqual(['fp-1', 'fp-2']);
52
+ expect(fake.seen).toHaveLength(1);
53
+ const sentUrl = new URL(`http://x${fake.seen[0]?.path}`);
54
+ expect(sentUrl.pathname).toBe(ORDERING_PATH);
55
+ expect(sentUrl.searchParams.get('sourceFirewallZoneId')).toBe('zone-iot');
56
+ expect(sentUrl.searchParams.get('destinationFirewallZoneId')).toBe('zone-external');
57
+ });
58
+
59
+ test('a genuine 404 folds to undefined', async () => {
60
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
61
+ const live = await Effect.runPromise(
62
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
63
+ );
64
+ expect(live).toBeUndefined();
65
+ });
66
+
67
+ test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
68
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
69
+ const failure = await Effect.runPromise(
70
+ Effect.flip(
71
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
72
+ ),
73
+ );
74
+ expect(failure._tag).toBe('InternalServerError');
75
+ });
76
+ });
77
+
78
+ describe('declareFirewallPolicyOrdering -- the declaration renderer', () => {
79
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
80
+ const live = liveOrdering({
81
+ orderedFirewallPolicyIds: { beforeSystemDefined: ['fp-5'], afterSystemDefined: [] },
82
+ });
83
+ const declared = declareFirewallPolicyOrdering(live, 'site-1', 'zone-iot', 'zone-external');
84
+ const attrs = attributesOf(live, declared);
85
+ expect(matches(attrs, declared)).toBe(true);
86
+ });
87
+ });
88
+
89
+ describe('T5 -- order IS the value, never sortedSet, independently on each half', () => {
90
+ test('an identical pair of lists is a noop', () => {
91
+ expect(driftOf(liveOrdering(), PROPS)).toEqual([]);
92
+ expect(matches(attributesOf(liveOrdering(), PROPS), PROPS)).toBe(true);
93
+ });
94
+
95
+ test('the SAME ids in a DIFFERENT order within beforeSystemDefined is real drift', () => {
96
+ const live = liveOrdering({
97
+ orderedFirewallPolicyIds: {
98
+ beforeSystemDefined: ['fp-2', 'fp-1'],
99
+ afterSystemDefined: ['fp-9'],
100
+ },
101
+ });
102
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
103
+ expect(driftOf(live, PROPS)).toEqual([
104
+ { field: 'beforeSystemDefined', live: ['fp-2', 'fp-1'], declared: ['fp-1', 'fp-2'] },
105
+ ]);
106
+ });
107
+
108
+ test('a reorder within afterSystemDefined alone is caught, beforeSystemDefined untouched', () => {
109
+ const live = liveOrdering({
110
+ orderedFirewallPolicyIds: {
111
+ beforeSystemDefined: ['fp-1', 'fp-2'],
112
+ afterSystemDefined: ['fp-9', 'fp-10'],
113
+ },
114
+ });
115
+ const props: FirewallPolicyOrderingProps = { ...PROPS, afterSystemDefined: ['fp-10', 'fp-9'] };
116
+ expect(driftOf(live, props)).toEqual([
117
+ { field: 'afterSystemDefined', live: ['fp-9', 'fp-10'], declared: ['fp-10', 'fp-9'] },
118
+ ]);
119
+ });
120
+
121
+ test('a policy moving from beforeSystemDefined to afterSystemDefined drifts on both fields', () => {
122
+ // The two halves can never swap through this endpoint by "reordering" alone -- membership moving
123
+ // across them is a genuine drift on each side independently, not one combined-list reorder.
124
+ const live = liveOrdering({
125
+ orderedFirewallPolicyIds: {
126
+ beforeSystemDefined: ['fp-1'],
127
+ afterSystemDefined: ['fp-2', 'fp-9'],
128
+ },
129
+ });
130
+ expect(driftOf(live, PROPS)).toEqual([
131
+ { field: 'beforeSystemDefined', live: ['fp-1'], declared: ['fp-1', 'fp-2'] },
132
+ { field: 'afterSystemDefined', live: ['fp-2', 'fp-9'], declared: ['fp-9'] },
133
+ ]);
134
+ });
135
+ });
136
+
137
+ describe('Unifi.FirewallPolicyOrdering write paths never reach the vendor API', () => {
138
+ const ops = unifiOperations(spec);
139
+
140
+ test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
141
+ const fake = fakeUnifi((method, url) =>
142
+ method === 'GET' && url.pathname === ORDERING_PATH
143
+ ? Response.json(liveOrdering())
144
+ : fakeFailure(400, 'unexpected request'),
145
+ );
146
+ const exit = await Effect.runPromiseExit(
147
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
148
+ );
149
+ expect(exit._tag).toBe('Success');
150
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
151
+ });
152
+
153
+ test('reconcile on drift (a reorder) refuses instead of PUTting the whole pair', async () => {
154
+ const fake = fakeUnifi((method, url) =>
155
+ method === 'GET' && url.pathname === ORDERING_PATH
156
+ ? Response.json(
157
+ liveOrdering({
158
+ orderedFirewallPolicyIds: {
159
+ beforeSystemDefined: ['fp-2', 'fp-1'],
160
+ afterSystemDefined: [],
161
+ },
162
+ }),
163
+ )
164
+ : fakeFailure(400, 'unexpected request'),
165
+ );
166
+ const exit = await Effect.runPromiseExit(
167
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
168
+ );
169
+ expect(exit._tag).toBe('Failure');
170
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
171
+ });
172
+
173
+ test('delete always refuses -- no DELETE is ever sent', async () => {
174
+ const fake = fakeUnifi((method, url) =>
175
+ method === 'GET' && url.pathname === ORDERING_PATH
176
+ ? Response.json(liveOrdering())
177
+ : fakeFailure(400, 'unexpected request'),
178
+ );
179
+ const exit = await Effect.runPromiseExit(
180
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
181
+ );
182
+ expect(exit._tag).toBe('Failure');
183
+ expect(fake.seen).toEqual([]);
184
+ });
185
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `Unifi.FirewallPolicyOrdering` — the ordered `before`/`afterSystemDefined` policy-id lists for
3
+ * ONE `(sourceFirewallZoneId, destinationFirewallZoneId)` zone pair (`getFirewallPolicyOrdering`,
4
+ * `GET /v1/sites/{siteId}/firewall/policies/ordering`), READ-ONLY (`policy.ts`). T5/I4's per-family
5
+ * variant: `docs/unifi.md`'s own header named this "a future `Unifi.FirewallPolicyOrdering`" as a
6
+ * separate, bigger PR (see `acl-rule-ordering.ts`'s header for the same forward reference, now
7
+ * resolved) — this is that resource.
8
+ *
9
+ * ⚠️ NOT ONE OBJECT PER SITE, UNLIKE `acl-rule-ordering.ts` — the identity is the TRIPLE
10
+ * `(siteId, sourceFirewallZoneId, destinationFirewallZoneId)`, both zone ids passed as QUERY
11
+ * parameters on the same path every time (`firewall.ts`'s `GetFirewallPolicyOrderingRequest`).
12
+ * There is no "list every zone pair with an ordering" endpoint — a caller (a future Lane C import)
13
+ * has to already know which pairs exist, typically by walking `firewall.getFirewallPolicies`
14
+ * (T9's own 424-live-policy scale — B0b's `pageAll`, see `firewall-policy.test.ts`'s own decode
15
+ * proof) and grouping by each policy's `source.zoneId`/`destination.zoneId`. Nothing here performs
16
+ * that discovery — this resource is one already-known pair's ordering, same as
17
+ * `firewall-policy.ts`/`acl-rule.ts` are one already-known object's own fields.
18
+ */
19
+ import { Resource } from 'alchemy';
20
+ import { adopt } from 'alchemy/AdoptPolicy';
21
+ import * as Provider from 'alchemy/Provider';
22
+ import * as firewall from '@distilled.cloud/unifi-network/firewall';
23
+ import * as Effect from 'effect/Effect';
24
+ import {
25
+ type FirewallPolicyOrderingAttributes,
26
+ type FirewallPolicyOrderingProps,
27
+ attributesOf,
28
+ matches,
29
+ } from './firewall-policy-ordering-form.ts';
30
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
31
+ import { unifiHandlers } from './resource.ts';
32
+
33
+ export type {
34
+ FirewallPolicyOrderingAttributes,
35
+ FirewallPolicyOrderingProps,
36
+ } from './firewall-policy-ordering-form.ts';
37
+ export { declareFirewallPolicyOrdering, driftOf } from './firewall-policy-ordering-form.ts';
38
+
39
+ export interface UnifiFirewallPolicyOrdering extends Resource<
40
+ 'Unifi.FirewallPolicyOrdering',
41
+ FirewallPolicyOrderingProps,
42
+ FirewallPolicyOrderingAttributes,
43
+ never,
44
+ UnifiRequirements
45
+ > {}
46
+
47
+ export const UnifiFirewallPolicyOrdering = Resource<UnifiFirewallPolicyOrdering>(
48
+ 'Unifi.FirewallPolicyOrdering',
49
+ { defaultRemovalPolicy: 'retain' },
50
+ );
51
+
52
+ /** `firewallPolicyOrdering('iot-to-external', props)` — `adopt(true)` piped on by default (H5); see
53
+ * `network.ts`. */
54
+ export const firewallPolicyOrdering = (id: string, props: FirewallPolicyOrderingProps) =>
55
+ UnifiFirewallPolicyOrdering(id, props).pipe(adopt(true));
56
+
57
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
58
+ export const spec: UnifiSpec<
59
+ FirewallPolicyOrderingProps,
60
+ firewall.IntegrationFirewallPolicyOrderingDto,
61
+ FirewallPolicyOrderingAttributes,
62
+ firewall.GetFirewallPolicyOrderingError
63
+ > = {
64
+ type: 'Unifi.FirewallPolicyOrdering',
65
+ describe: (props) =>
66
+ `sites/${props.siteId}/firewall/policies/ordering` +
67
+ `?sourceFirewallZoneId=${props.sourceFirewallZoneId}` +
68
+ `&destinationFirewallZoneId=${props.destinationFirewallZoneId}`,
69
+ fetchLive: (props) =>
70
+ firewall
71
+ .getFirewallPolicyOrdering({
72
+ siteId: props.siteId,
73
+ sourceFirewallZoneId: props.sourceFirewallZoneId,
74
+ destinationFirewallZoneId: props.destinationFirewallZoneId,
75
+ })
76
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
77
+ attributes: attributesOf,
78
+ matches,
79
+ };
80
+
81
+ export const handlers = unifiHandlers(spec);
82
+
83
+ export const UnifiFirewallPolicyOrderingProvider = () =>
84
+ Provider.effect(
85
+ UnifiFirewallPolicyOrdering,
86
+ Effect.succeed(UnifiFirewallPolicyOrdering.Provider.of(handlers)),
87
+ );
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Unifi.FirewallPolicy`'s `spec` against a fake UniFi Network API — mirrors `acl-rule.test.ts`/
3
+ * `dns-policy.test.ts`.
4
+ *
5
+ * ⚠️ `driftOf`/`matches` coverage lives in `firewall-policy-drift.test.ts` (the field-level table
6
+ * plus I2's per-field completeness check) and `firewall-policy-filter-roundtrip.test.ts` (I1's
7
+ * filter-bearing round trip) — both split out to keep this file under the house 250-line cap once
8
+ * that coverage grew, same split `network-drift.test.ts` is from `network.test.ts`, not a
9
+ * different family. Red team, 2026-09-26.
10
+ *
11
+ * The last `describe` block is this family's own decode-proof of B0b's `pageAll` (T9) — not used by
12
+ * `spec.fetchLive` itself (`getFirewallPolicy` reads one policy by id directly, same as
13
+ * `acl-rule.ts`), but a real console's FirewallPolicy list is the one T9 names by scale (424 live
14
+ * policies, `alchemy-ledger-network.md:13`) — `paginate.test.ts`'s own "against the real SDK" section
15
+ * proves the pager's decode path against `networks.getNetworksOverviewPage`; this proves it against
16
+ * `firewall.getFirewallPolicies`'s own page shape too, so a future Lane C import walking this
17
+ * family's full list is walking a shape this suite has already exercised end to end.
18
+ */
19
+ import { describe, expect, test } from 'bun:test';
20
+ import * as firewall from '@distilled.cloud/unifi-network/firewall';
21
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
22
+ import * as Effect from 'effect/Effect';
23
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
24
+ import { attributesOf } from './firewall-policy-form.ts';
25
+ import { driftOf, matches } from './firewall-policy-drift.ts';
26
+ import { type FirewallPolicyProps, declareFirewallPolicy, spec } from './firewall-policy.ts';
27
+ import { pageAll } from './paginate.ts';
28
+ import { unifiOperations } from './resource.ts';
29
+
30
+ const POLICY_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/policies/fp-1';
31
+
32
+ const liveFirewallPolicy = (
33
+ overrides: Partial<firewall.FirewallPolicy> = {},
34
+ ): firewall.FirewallPolicy => ({
35
+ action: { type: 'ALLOW' },
36
+ connectionStateFilter: ['ESTABLISHED', 'RELATED'],
37
+ description: 'Allow IoT devices outbound to the internet',
38
+ destination: { zoneId: 'zone-external' },
39
+ enabled: true,
40
+ id: 'fp-1',
41
+ index: 3,
42
+ ipProtocolScope: { ipVersion: 'IPV4' },
43
+ loggingEnabled: false,
44
+ metadata: { origin: 'USER' },
45
+ name: 'Allow IoT to Internet',
46
+ source: { zoneId: 'zone-iot' },
47
+ ...overrides,
48
+ });
49
+
50
+ const PROPS: FirewallPolicyProps = {
51
+ siteId: 'site-1',
52
+ firewallPolicyId: 'fp-1',
53
+ action: { type: 'ALLOW' },
54
+ connectionStateFilter: ['ESTABLISHED', 'RELATED'],
55
+ description: 'Allow IoT devices outbound to the internet',
56
+ destination: { zoneId: 'zone-external' },
57
+ enabled: true,
58
+ ipProtocolScope: { ipVersion: 'IPV4' },
59
+ loggingEnabled: false,
60
+ name: 'Allow IoT to Internet',
61
+ source: { zoneId: 'zone-iot' },
62
+ };
63
+
64
+ describe('Unifi.FirewallPolicy spec.fetchLive', () => {
65
+ test('a real GET response decodes into the typed live object', async () => {
66
+ const fake = fakeUnifi((method, url) =>
67
+ method === 'GET' && url.pathname === POLICY_PATH
68
+ ? Response.json(liveFirewallPolicy())
69
+ : fakeFailure(400, 'unexpected request'),
70
+ );
71
+ const live = await Effect.runPromise(
72
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
73
+ );
74
+ expect(live?.id).toBe('fp-1');
75
+ expect(fake.seen).toEqual([{ method: 'GET', path: POLICY_PATH }]);
76
+ });
77
+
78
+ test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
79
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
80
+ const live = await Effect.runPromise(
81
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
82
+ );
83
+ expect(live).toBeUndefined();
84
+ });
85
+
86
+ test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
87
+ // ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
88
+ // asserts the FAILURE, not the retry schedule.
89
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
90
+ const failure = await Effect.runPromise(
91
+ Effect.flip(
92
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
93
+ ),
94
+ );
95
+ expect(failure._tag).toBe('InternalServerError');
96
+ });
97
+ });
98
+
99
+ describe('declareFirewallPolicy -- the declaration renderer', () => {
100
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
101
+ const live = liveFirewallPolicy({
102
+ connectionStateFilter: ['RELATED', 'ESTABLISHED'],
103
+ index: 9,
104
+ });
105
+ const declared = declareFirewallPolicy(live, 'site-1');
106
+ const attrs = attributesOf(live, declared);
107
+ expect(matches(attrs, declared)).toBe(true);
108
+ expect(driftOf(live, declared)).toEqual([]);
109
+ });
110
+ });
111
+
112
+ describe('Unifi.FirewallPolicy write paths never reach the vendor API', () => {
113
+ const ops = unifiOperations(spec);
114
+
115
+ test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
116
+ const fake = fakeUnifi((method, url) =>
117
+ method === 'GET' && url.pathname === POLICY_PATH
118
+ ? Response.json(liveFirewallPolicy())
119
+ : fakeFailure(400, 'unexpected request'),
120
+ );
121
+ const exit = await Effect.runPromiseExit(
122
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
123
+ );
124
+ expect(exit._tag).toBe('Success');
125
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
126
+ });
127
+
128
+ test('reconcile on drift refuses instead of PATCHing/PUTting a merged body', async () => {
129
+ const fake = fakeUnifi((method, url) =>
130
+ method === 'GET' && url.pathname === POLICY_PATH
131
+ ? Response.json(liveFirewallPolicy({ enabled: false }))
132
+ : fakeFailure(400, 'unexpected request'),
133
+ );
134
+ const exit = await Effect.runPromiseExit(
135
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
136
+ );
137
+ expect(exit._tag).toBe('Failure');
138
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
139
+ });
140
+
141
+ test('delete always refuses -- no DELETE is ever sent', async () => {
142
+ const fake = fakeUnifi((method, url) =>
143
+ method === 'GET' && url.pathname === POLICY_PATH
144
+ ? Response.json(liveFirewallPolicy())
145
+ : fakeFailure(400, 'unexpected request'),
146
+ );
147
+ const exit = await Effect.runPromiseExit(
148
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
149
+ );
150
+ expect(exit._tag).toBe('Failure');
151
+ expect(fake.seen).toEqual([]);
152
+ });
153
+ });
154
+
155
+ describe("T9/B0b -- pageAll walks this family's own page shape (decode proof)", () => {
156
+ test('every row across several pages of getFirewallPolicies decodes, in order', async () => {
157
+ const rows = Array.from({ length: 7 }, (_, i) =>
158
+ liveFirewallPolicy({ id: `fp-${i}`, index: i }),
159
+ );
160
+ const fake = fakeUnifi((method, url) => {
161
+ if (
162
+ method !== 'GET' ||
163
+ url.pathname !== '/proxy/network/integration/v1/sites/site-1/firewall/policies'
164
+ ) {
165
+ return fakeFailure(400, 'unexpected request');
166
+ }
167
+ const offset = Number(url.searchParams.get('offset') ?? '0');
168
+ const limit = Number(url.searchParams.get('limit') ?? '3');
169
+ const data = rows.slice(offset, offset + limit);
170
+ return Response.json({ count: data.length, data, limit, offset, totalCount: rows.length });
171
+ });
172
+
173
+ const result = await Effect.runPromise(
174
+ pageAll(firewall.getFirewallPolicies, { siteId: 'site-1', limit: 3 }).pipe(
175
+ Effect.provide(fakeUnifiLayer(fake.fetch)),
176
+ ),
177
+ );
178
+
179
+ expect(result.map((p) => p.id)).toEqual(rows.map((r) => r.id));
180
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
181
+ expect(fake.seen).toHaveLength(3);
182
+ });
183
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `Unifi.FirewallPolicy` — one firewall policy on a UniFi Network site, READ-ONLY (`policy.ts`).
3
+ * `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`) is the sibling resource for policy
4
+ * ORDER within a zone pair (T5) — one per `(sourceFirewallZoneId, destinationFirewallZoneId)`, never
5
+ * this file's concern. `docs/unifi.md`'s own header names this pair as "a bigger, separate PR";
6
+ * this is that PR — see `docs/unifi-firewall-policy.md` for the full breakdown.
7
+ *
8
+ * ★ T11 GATE, CHECKED 2026-09-26: the `Firewall` tag's 13 operations (5 already covered for
9
+ * `Unifi.FirewallZone` in `docs/unifi.md`, 8 new here for `FirewallPolicy` — `getFirewallPolicies`,
10
+ * `createFirewallPolicy`, `getFirewallPolicyOrdering`, `updateFirewallPolicyOrdering`,
11
+ * `deleteFirewallPolicy`, `getFirewallPolicy`, `patchFirewallPolicy`, `updateFirewallPolicy`) are
12
+ * byte-identical between 10.4.57 and 10.6.97, full raw operation objects; the 106-schema closure
13
+ * reachable from all 13 (`$ref` + `discriminator.mapping`) shares zero names with the 14 schemas
14
+ * that DID change elsewhere in the document. Corroborated independently by the A3 changeset's own
15
+ * broader 25-operation/139-schema re-check (`.changeset/unifi-network-a3-typed-discriminator-filters.md`).
16
+ * Full breakdown: `docs/unifi-firewall-policy.md`.
17
+ */
18
+ import { Resource } from 'alchemy';
19
+ import { adopt } from 'alchemy/AdoptPolicy';
20
+ import * as Provider from 'alchemy/Provider';
21
+ import * as firewall from '@distilled.cloud/unifi-network/firewall';
22
+ import * as Effect from 'effect/Effect';
23
+ import {
24
+ type FirewallPolicyAttributes,
25
+ type FirewallPolicyProps,
26
+ attributesOf,
27
+ } from './firewall-policy-form.ts';
28
+ import { matches } from './firewall-policy-drift.ts';
29
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
30
+ import { unifiHandlers } from './resource.ts';
31
+
32
+ export type { FirewallPolicyAttributes, FirewallPolicyProps } from './firewall-policy-form.ts';
33
+ export { declareFirewallPolicy } from './firewall-policy-form.ts';
34
+ export { driftOf } from './firewall-policy-drift.ts';
35
+
36
+ export interface UnifiFirewallPolicy extends Resource<
37
+ 'Unifi.FirewallPolicy',
38
+ FirewallPolicyProps,
39
+ FirewallPolicyAttributes,
40
+ never,
41
+ UnifiRequirements
42
+ > {}
43
+
44
+ export const UnifiFirewallPolicy = Resource<UnifiFirewallPolicy>('Unifi.FirewallPolicy', {
45
+ defaultRemovalPolicy: 'retain',
46
+ });
47
+
48
+ /** `firewallPolicy('block-iot-wan', props)` — `adopt(true)` piped on by default (H5); see
49
+ * `network.ts`. */
50
+ export const firewallPolicy = (id: string, props: FirewallPolicyProps) =>
51
+ UnifiFirewallPolicy(id, props).pipe(adopt(true));
52
+
53
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
54
+ export const spec: UnifiSpec<
55
+ FirewallPolicyProps,
56
+ firewall.FirewallPolicy,
57
+ FirewallPolicyAttributes,
58
+ firewall.GetFirewallPolicyError
59
+ > = {
60
+ type: 'Unifi.FirewallPolicy',
61
+ describe: (props) => `sites/${props.siteId}/firewall/policies/${props.firewallPolicyId}`,
62
+ fetchLive: (props) =>
63
+ firewall
64
+ .getFirewallPolicy({ siteId: props.siteId, firewallPolicyId: props.firewallPolicyId })
65
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
66
+ attributes: attributesOf,
67
+ matches,
68
+ };
69
+
70
+ export const handlers = unifiHandlers(spec);
71
+
72
+ export const UnifiFirewallPolicyProvider = () =>
73
+ Provider.effect(UnifiFirewallPolicy, Effect.succeed(UnifiFirewallPolicy.Provider.of(handlers)));
@@ -13,6 +13,7 @@
13
13
  * the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.
14
14
  */
15
15
  import type * as firewall from '@distilled.cloud/unifi-network/firewall';
16
+ import { makeDriftOf } from './drift.ts';
16
17
 
17
18
  export interface FirewallZoneProps {
18
19
  siteId: string;
@@ -45,9 +46,37 @@ export const attributesOf = (
45
46
  metadataOrigin: live.metadata.origin,
46
47
  });
47
48
 
49
+ /**
50
+ * B6: `fieldDrift` (`makeDriftOf`, `drift.ts`) is now the ONLY comparison this family has — MEDIUM-4
51
+ * (red team, 2026-09-26): a hand-written `matches` kept beside `fieldDrift` is two field lists an
52
+ * edit to one can silently leave out of sync with the other (a mutant deleting a `fieldDrift` entry
53
+ * left every test green, because `matches` never consulted it). `matches(attrs, props) ===
54
+ * (driftOf(live, props).length === 0)` is now true BY CONSTRUCTION, not just by a test that happens
55
+ * to check both. `networkIds`' custom `equal` is the set comparison the family has always used —
56
+ * see the header — and its reported `live`/`declared` values in a `driftOf` result are the SORTED
57
+ * arrays, not the joined strings this `equal` compares internally, so a real diff reads as a set
58
+ * difference, not two opaque strings.
59
+ */
60
+ const fieldDrift = makeDriftOf<FirewallZoneAttributes, FirewallZoneProps>([
61
+ { field: 'name', live: (a) => a.name, declared: (p) => p.name },
62
+ {
63
+ field: 'networkIds',
64
+ live: (a) => a.networkIds,
65
+ declared: (p) => sortedSet(p.networkIds),
66
+ equal: (live, declared) => (live as string[]).join(',') === (declared as string[]).join(','),
67
+ },
68
+ ]);
69
+
48
70
  export const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>
49
- attributes.name === props.name &&
50
- attributes.networkIds.join(',') === sortedSet(props.networkIds).join(',');
71
+ fieldDrift(attributes, props).length === 0;
72
+
73
+ /**
74
+ * Per-field live-vs-declared drift straight from one live read — the task's own API
75
+ * (`driftOf(live, props)`), so `homeflare-network`'s pre-import drift check never has to derive
76
+ * `attributesOf` itself just to call this.
77
+ */
78
+ export const driftOf = (live: firewall.FirewallZone, props: FirewallZoneProps) =>
79
+ fieldDrift(attributesOf(live, props), props);
51
80
 
52
81
  /**
53
82
  * The declaration renderer (task spec: "given one live object, return the props a declaration
@@ -5,7 +5,7 @@ import { describe, expect, test } from 'bun:test';
5
5
  import type * as firewall from '@distilled.cloud/unifi-network/firewall';
6
6
  import * as Effect from 'effect/Effect';
7
7
  import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
8
- import { attributesOf, matches } from './firewall-zone-form.ts';
8
+ import { attributesOf, driftOf, matches } from './firewall-zone-form.ts';
9
9
  import { type FirewallZoneProps, declareFirewallZone, spec } from './firewall-zone.ts';
10
10
  import { unifiOperations } from './resource.ts';
11
11
 
@@ -59,6 +59,26 @@ describe('declareFirewallZone -- the declaration renderer', () => {
59
59
  });
60
60
  });
61
61
 
62
+ describe('driftOf -- B6 field-level drift, straight from one live read', () => {
63
+ test('a live object that matches its declaration drifts on nothing, order included', () => {
64
+ // `networkIds` in a different order is a noop for `matches` (the header's own rule) --
65
+ // `driftOf` must agree, not report a set that only LOOKS different because of order.
66
+ expect(driftOf(liveZone(), PROPS)).toEqual([]);
67
+ });
68
+
69
+ test('a real name change reports its own field, live and declared', () => {
70
+ const live = liveZone({ name: 'Garage' });
71
+ expect(driftOf(live, PROPS)).toEqual([{ field: 'name', live: 'Garage', declared: 'Cameras' }]);
72
+ });
73
+
74
+ test('a genuine networkIds difference reports SORTED arrays, not joined strings', () => {
75
+ const live = liveZone({ networkIds: ['net-1'] });
76
+ expect(driftOf(live, PROPS)).toEqual([
77
+ { field: 'networkIds', live: ['net-1'], declared: ['net-1', 'net-2'] },
78
+ ]);
79
+ });
80
+ });
81
+
62
82
  describe('Unifi.FirewallZone write paths never reach the vendor API', () => {
63
83
  test('reconcile on an exact-match adoption (H6) sends only the one read', async () => {
64
84
  const fake = fakeUnifi((method, url) =>
@@ -25,7 +25,7 @@ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
25
25
  import { unifiHandlers } from './resource.ts';
26
26
 
27
27
  export type { FirewallZoneAttributes, FirewallZoneProps } from './firewall-zone-form.ts';
28
- export { declareFirewallZone } from './firewall-zone-form.ts';
28
+ export { declareFirewallZone, driftOf } from './firewall-zone-form.ts';
29
29
 
30
30
  export interface UnifiFirewallZone extends Resource<
31
31
  'Unifi.FirewallZone',