@homeflare/alchemy 0.39.0 → 0.41.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 (279) hide show
  1. package/README.md +10 -8
  2. package/dist/caddy/index.js.map +2 -2
  3. package/dist/ceph/auth-entity.d.ts +34 -0
  4. package/dist/ceph/auth-entity.d.ts.map +1 -0
  5. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  6. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  7. package/dist/ceph/ceph-argv.d.ts +19 -0
  8. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  9. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  10. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  12. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  14. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  15. package/dist/ceph/ceph-config-options.d.ts +23 -0
  16. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  18. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  19. package/dist/ceph/ceph-transport.d.ts +37 -0
  20. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  22. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  23. package/dist/ceph/index.d.ts +13 -0
  24. package/dist/ceph/index.d.ts.map +1 -0
  25. package/dist/ceph/index.js +1610 -0
  26. package/dist/ceph/index.js.map +38 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/launchd/fake-runner.d.ts.map +1 -1
  30. package/dist/launchd/index.d.ts +1 -1
  31. package/dist/launchd/index.d.ts.map +1 -1
  32. package/dist/launchd/index.js +7 -1
  33. package/dist/launchd/index.js.map +5 -5
  34. package/dist/launchd/local-runner.d.ts.map +1 -1
  35. package/dist/launchd/runner.d.ts +21 -0
  36. package/dist/launchd/runner.d.ts.map +1 -1
  37. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  38. package/dist/linux/directory-lifecycle.d.ts.map +1 -1
  39. package/dist/linux/fake-linux-host.d.ts.map +1 -1
  40. package/dist/linux/index.d.ts +1 -1
  41. package/dist/linux/index.d.ts.map +1 -1
  42. package/dist/linux/index.js +16 -4
  43. package/dist/linux/index.js.map +7 -7
  44. package/dist/linux/ssh-runner.d.ts.map +1 -1
  45. package/dist/linux/sudo-runner.d.ts.map +1 -1
  46. package/dist/opnsense/alias-form.d.ts +14 -1
  47. package/dist/opnsense/alias-form.d.ts.map +1 -1
  48. package/dist/opnsense/alias.d.ts +1 -1
  49. package/dist/opnsense/alias.d.ts.map +1 -1
  50. package/dist/opnsense/group-form.d.ts +8 -2
  51. package/dist/opnsense/group-form.d.ts.map +1 -1
  52. package/dist/opnsense/group.d.ts +1 -1
  53. package/dist/opnsense/group.d.ts.map +1 -1
  54. package/dist/opnsense/index.js +15 -7
  55. package/dist/opnsense/index.js.map +7 -7
  56. package/dist/opnsense/wire.d.ts +42 -12
  57. package/dist/opnsense/wire.d.ts.map +1 -1
  58. package/dist/proxmox/index.js +62 -39
  59. package/dist/proxmox/index.js.map +8 -7
  60. package/dist/proxmox/qemu-volume.d.ts +12 -2
  61. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  62. package/dist/proxmox/storage-form.d.ts +28 -3
  63. package/dist/proxmox/storage-form.d.ts.map +1 -1
  64. package/dist/proxmox/storage-plugin-options.d.ts +2 -0
  65. package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
  66. package/dist/proxmox/storage-wire.d.ts +28 -1
  67. package/dist/proxmox/storage-wire.d.ts.map +1 -1
  68. package/dist/proxmox/storage.d.ts +6 -1
  69. package/dist/proxmox/storage.d.ts.map +1 -1
  70. package/dist/release/index.d.ts +1 -1
  71. package/dist/release/index.d.ts.map +1 -1
  72. package/dist/release/index.js.map +2 -2
  73. package/dist/talos/credentials-write.d.ts +46 -0
  74. package/dist/talos/credentials-write.d.ts.map +1 -0
  75. package/dist/talos/credentials.d.ts +51 -9
  76. package/dist/talos/credentials.d.ts.map +1 -1
  77. package/dist/talos/fake-process.d.ts +56 -0
  78. package/dist/talos/fake-process.d.ts.map +1 -0
  79. package/dist/talos/index.d.ts +19 -4
  80. package/dist/talos/index.d.ts.map +1 -1
  81. package/dist/talos/index.js +357 -173
  82. package/dist/talos/index.js.map +16 -10
  83. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  84. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  85. package/dist/talos/kubeconfig.d.ts +41 -23
  86. package/dist/talos/kubeconfig.d.ts.map +1 -1
  87. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  88. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  89. package/dist/talos/machine-config-poll.d.ts +43 -0
  90. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  91. package/dist/talos/machine-config-read.d.ts +49 -0
  92. package/dist/talos/machine-config-read.d.ts.map +1 -0
  93. package/dist/talos/resource.d.ts +18 -0
  94. package/dist/talos/resource.d.ts.map +1 -1
  95. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  96. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  97. package/dist/talos/talos-bootstrap.d.ts +87 -0
  98. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  99. package/dist/talos/talos-cluster-health.d.ts +43 -1
  100. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  101. package/dist/talos/talos-errors.d.ts +68 -0
  102. package/dist/talos/talos-errors.d.ts.map +1 -0
  103. package/dist/talos/talos-machine-config.d.ts +51 -15
  104. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  105. package/dist/talos/talosctl.d.ts +7 -0
  106. package/dist/talos/talosctl.d.ts.map +1 -1
  107. package/dist/talos/values.d.ts +23 -3
  108. package/dist/talos/values.d.ts.map +1 -1
  109. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  110. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  111. package/dist/unifi/acl-rule-form.d.ts +90 -0
  112. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  113. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  114. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  115. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  116. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  117. package/dist/unifi/acl-rule.d.ts +49 -0
  118. package/dist/unifi/acl-rule.d.ts.map +1 -0
  119. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  120. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  121. package/dist/unifi/dns-policy-form.d.ts +97 -0
  122. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  123. package/dist/unifi/dns-policy.d.ts +55 -0
  124. package/dist/unifi/dns-policy.d.ts.map +1 -0
  125. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  126. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  127. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  128. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  129. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  130. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  131. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  132. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  133. package/dist/unifi/firewall-policy.d.ts +57 -0
  134. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  135. package/dist/unifi/index.d.ts +7 -0
  136. package/dist/unifi/index.d.ts.map +1 -1
  137. package/dist/unifi/index.js +588 -3
  138. package/dist/unifi/index.js.map +24 -4
  139. package/dist/unifi/paginate.d.ts +0 -12
  140. package/dist/unifi/paginate.d.ts.map +1 -1
  141. package/dist/unifi/providers.d.ts +1 -1
  142. package/dist/unifi/providers.d.ts.map +1 -1
  143. package/dist/unifi/scan-source.d.ts +11 -0
  144. package/dist/unifi/scan-source.d.ts.map +1 -0
  145. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  146. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  147. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  148. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  149. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  150. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  151. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  152. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  153. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  154. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  155. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  156. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  157. package/docs/distilled-interim.md +7 -0
  158. package/docs/launchd.md +5 -3
  159. package/docs/provider-standard.md +5 -1
  160. package/docs/talos-argocd-dogfood.md +160 -0
  161. package/docs/unifi-acl-rule.md +122 -0
  162. package/docs/unifi-dns-policy.md +75 -0
  163. package/docs/unifi-firewall-policy.md +109 -0
  164. package/docs/unifi-traffic-matching-list.md +82 -0
  165. package/docs/unifi-wifi.md +120 -0
  166. package/docs/unifi.md +10 -5
  167. package/package.json +7 -3
  168. package/src/ceph/auth-entity.ts +74 -0
  169. package/src/ceph/ceph-argv-config.ts +78 -0
  170. package/src/ceph/ceph-argv.test.ts +226 -0
  171. package/src/ceph/ceph-argv.ts +189 -0
  172. package/src/ceph/ceph-auth-form.test.ts +103 -0
  173. package/src/ceph/ceph-auth-form.ts +107 -0
  174. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  175. package/src/ceph/ceph-auth-parse.ts +121 -0
  176. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  177. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  178. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  179. package/src/ceph/ceph-config-options.test.ts +44 -0
  180. package/src/ceph/ceph-config-options.ts +36 -0
  181. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  182. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  183. package/src/ceph/ceph-transport.test.ts +125 -0
  184. package/src/ceph/ceph-transport.ts +117 -0
  185. package/src/ceph/fake-ceph-dial.ts +55 -0
  186. package/src/ceph/index.ts +12 -0
  187. package/src/index.ts +1 -0
  188. package/src/launchd/fake-runner.ts +2 -0
  189. package/src/launchd/index.ts +8 -1
  190. package/src/launchd/local-runner.ts +16 -1
  191. package/src/launchd/runner.ts +22 -0
  192. package/src/launchd/sudo-runner.ts +3 -0
  193. package/src/linux/directory-lifecycle.ts +18 -3
  194. package/src/linux/directory-sudo.test.ts +59 -0
  195. package/src/linux/directory.test.ts +34 -8
  196. package/src/linux/fake-linux-host.ts +2 -0
  197. package/src/linux/index.ts +1 -0
  198. package/src/linux/ssh-runner.ts +4 -0
  199. package/src/linux/sudo-runner.ts +3 -0
  200. package/src/opnsense/alias-form.test.ts +24 -12
  201. package/src/opnsense/alias-form.ts +20 -7
  202. package/src/opnsense/alias.test.ts +13 -6
  203. package/src/opnsense/alias.ts +16 -10
  204. package/src/opnsense/group-form.test.ts +12 -8
  205. package/src/opnsense/group-form.ts +13 -4
  206. package/src/opnsense/group.test.ts +18 -7
  207. package/src/opnsense/group.ts +10 -3
  208. package/src/opnsense/wire.ts +57 -12
  209. package/src/proxmox/qemu-volume.test.ts +153 -4
  210. package/src/proxmox/qemu-volume.ts +71 -14
  211. package/src/proxmox/storage-form.ts +48 -8
  212. package/src/proxmox/storage-plugin-options.ts +45 -0
  213. package/src/proxmox/storage-update.test.ts +183 -0
  214. package/src/proxmox/storage-wire.ts +54 -7
  215. package/src/proxmox/storage.ts +18 -6
  216. package/src/release/index.ts +1 -0
  217. package/src/talos/credentials-write.test.ts +81 -0
  218. package/src/talos/credentials-write.ts +95 -0
  219. package/src/talos/credentials.test.ts +157 -0
  220. package/src/talos/credentials.ts +160 -90
  221. package/src/talos/fake-process.ts +94 -0
  222. package/src/talos/index.ts +39 -4
  223. package/src/talos/kubeconfig-attrs.ts +55 -0
  224. package/src/talos/kubeconfig.test.ts +256 -0
  225. package/src/talos/kubeconfig.ts +170 -90
  226. package/src/talos/machine-config-fixtures.ts +86 -0
  227. package/src/talos/machine-config-poll.test.ts +57 -0
  228. package/src/talos/machine-config-poll.ts +82 -0
  229. package/src/talos/machine-config-read.ts +118 -0
  230. package/src/talos/resource.ts +19 -0
  231. package/src/talos/secrets-gate.test.ts +63 -0
  232. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  233. package/src/talos/talos-bootstrap.test.ts +218 -0
  234. package/src/talos/talos-bootstrap.ts +151 -45
  235. package/src/talos/talos-cluster-health.test.ts +143 -0
  236. package/src/talos/talos-cluster-health.ts +91 -28
  237. package/src/talos/talos-errors.ts +85 -0
  238. package/src/talos/talos-machine-config.test.ts +189 -0
  239. package/src/talos/talos-machine-config.ts +102 -88
  240. package/src/talos/talosctl.ts +8 -0
  241. package/src/talos/values.test.ts +34 -6
  242. package/src/talos/values.ts +41 -7
  243. package/src/unifi/acl-rule-drift.ts +57 -0
  244. package/src/unifi/acl-rule-form.ts +135 -0
  245. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  246. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  247. package/src/unifi/acl-rule-ordering.ts +70 -0
  248. package/src/unifi/acl-rule.test.ts +183 -0
  249. package/src/unifi/acl-rule.ts +61 -0
  250. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  251. package/src/unifi/dns-policy-drift.ts +48 -0
  252. package/src/unifi/dns-policy-form.ts +142 -0
  253. package/src/unifi/dns-policy.test.ts +154 -0
  254. package/src/unifi/dns-policy.ts +67 -0
  255. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  256. package/src/unifi/firewall-policy-drift.ts +55 -0
  257. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  258. package/src/unifi/firewall-policy-form.ts +146 -0
  259. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  260. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  261. package/src/unifi/firewall-policy-ordering.ts +87 -0
  262. package/src/unifi/firewall-policy.test.ts +183 -0
  263. package/src/unifi/firewall-policy.ts +73 -0
  264. package/src/unifi/index.ts +63 -0
  265. package/src/unifi/paginate.test.ts +24 -0
  266. package/src/unifi/paginate.ts +15 -3
  267. package/src/unifi/providers.ts +19 -1
  268. package/src/unifi/scan-source.ts +64 -0
  269. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  270. package/src/unifi/traffic-matching-list-form.ts +77 -0
  271. package/src/unifi/traffic-matching-list.test.ts +202 -0
  272. package/src/unifi/traffic-matching-list.ts +78 -0
  273. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  274. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  275. package/src/unifi/wifi-broadcast-form.ts +211 -0
  276. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  277. package/src/unifi/wifi-broadcast.test.ts +155 -0
  278. package/src/unifi/wifi-broadcast.ts +90 -0
  279. package/src/unifi/write-op-reference.test.ts +8 -54
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `Unifi.AclRule`'s wire shape — the declarable subset of `ACLRule` (`getAclRule`'s own decode
3
+ * target — `ACLRuleObject` is the structurally-identical page-list item type, unused here), and
4
+ * how a live read
5
+ * becomes both plan attributes and a rendered declaration. Rule ORDER is a separate resource
6
+ * (`acl-rule-ordering.ts`, T5) — this file is one rule's own fields only.
7
+ *
8
+ * ⚠️ `sourceFilter`/`destinationFilter` STAY `unknown`, ON THE SDK'S OWN AUTHORITY.
9
+ * `access_control_acl_rules.ts`'s own comment on both ("Shape varies by variant — widened by
10
+ * scripts/convert.ts; see README") says the OpenAPI discriminator these vary over was flattened
11
+ * at generation time (T10), same as `network-form.ts`'s `ipv4Configuration`. A3 (typing these
12
+ * per-discriminator) is scoped OUT of this PR — see the plan's family inventory — so both stay
13
+ * opaque JSON, compared with plain `deepEqual`, honest about not being decoded.
14
+ *
15
+ * ⚠️ KNOWN GAP UNTIL A3 (T15, red team, Minor finding 4): the vendor's own variant DTOs for these
16
+ * filters carry SET-LIKE arrays too (`networkIds`, `ipAddressesOrSubnets`, `portFilter`,
17
+ * `macAddresses` — `docs/unifi-acl-rule.md`'s own field notes). Because the filter stays opaque,
18
+ * `deepEqual` compares those nested arrays order-sensitively, the same false-`update` risk this
19
+ * file already removes for `deviceIds`/`protocolFilter` by normalizing them — but here there is
20
+ * no decoded field to normalize. Accepted as a known gap, not fixed, until A3 types the filter
21
+ * per-discriminator and can apply `sortedSet` to its own array fields.
22
+ *
23
+ * ⛔ `index` IS ATTRIBUTES-ONLY, NEVER DECLARED. `CreateAclRuleRequest`'s own field comment:
24
+ * "ACL rule index. This property is deprecated and has no effect. Use the dedicated ACL rule
25
+ * reordering endpoint." The live object's current position is `acl-rule-ordering.ts`'s concern
26
+ * (`ACLRuleOrdering.orderedAclRuleIds`), not this resource's declared identity — reporting it
27
+ * here without comparing it avoids a field that can never converge under this family's own
28
+ * `matches` (nothing here could ever change it) from silently pretending to be declarable.
29
+ *
30
+ * ⛔ `enforcingDeviceFilter.deviceIds` AND `protocolFilter` ARE SETS, NOT SEQUENCES — same
31
+ * reasoning `firewall-zone-form.ts` gives for `networkIds`: "IDs of the Switch-capable devices
32
+ * used to enforce the ACL rule" and "Protocols this ACL rule will be applied to" both describe
33
+ * membership, with no indication either list is ordered like `acl-rule-ordering.ts`'s rule-id
34
+ * sequence. Left alone, a console that returns either in a different order between calls would
35
+ * plan a spurious `update` that this read-only family's `reconcile` then refuses forever.
36
+ */
37
+ import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
38
+
39
+ const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
40
+
41
+ /** ★ EXPORTED so `acl-rule-drift.ts` normalizes both live and declared sides identically — see
42
+ * `network-form.ts`'s own header for why a normalizer applied to only one side is a bug class. */
43
+ export const normalizeDeviceFilter = (
44
+ value: aclRules.ACLRuleDeviceFilter | undefined,
45
+ ): aclRules.ACLRuleDeviceFilter | undefined =>
46
+ value == null
47
+ ? value
48
+ : {
49
+ ...value,
50
+ ...(value.deviceIds !== undefined ? { deviceIds: sortedSet(value.deviceIds) } : {}),
51
+ };
52
+
53
+ export const normalizeProtocolFilter = (
54
+ value: readonly string[] | undefined,
55
+ ): string[] | undefined => (value == null ? undefined : sortedSet(value));
56
+
57
+ /** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
58
+ * `network-form.ts`'s own header. */
59
+ export interface AclRuleProps {
60
+ siteId: string;
61
+ aclRuleId: string;
62
+ /** `ALLOW` or `BLOCK` (also accepts a future vendor value the spec did not enumerate). */
63
+ action: string;
64
+ description?: string | undefined;
65
+ /** Traffic destination filter — opaque, see the header. */
66
+ destinationFilter?: unknown;
67
+ enabled: boolean;
68
+ /** Devices enforcing this rule — compared as a SET, see the header. */
69
+ enforcingDeviceFilter?: aclRules.ACLRuleDeviceFilter | undefined;
70
+ name: string;
71
+ /** Traffic source filter — opaque, see the header. */
72
+ sourceFilter?: unknown;
73
+ type: string;
74
+ /** Protocols this rule applies to — compared as a SET, see the header. */
75
+ protocolFilter?: string[] | undefined;
76
+ networkIdFilter?: string | undefined;
77
+ }
78
+
79
+ export interface AclRuleAttributes {
80
+ siteId: string;
81
+ aclRuleId: string;
82
+ action: string;
83
+ description: string | undefined;
84
+ destinationFilter: unknown;
85
+ enabled: boolean;
86
+ enforcingDeviceFilter: aclRules.ACLRuleDeviceFilter | undefined;
87
+ /** ACL rule index (current priority position) — server-derived, never declared; see header. */
88
+ index: number;
89
+ name: string;
90
+ sourceFilter: unknown;
91
+ type: string;
92
+ protocolFilter: string[] | undefined;
93
+ networkIdFilter: string | undefined;
94
+ /** `metadata.origin` — server-derived, never declared; see `firewall-zone-form.ts`'s own. */
95
+ metadataOrigin: string;
96
+ }
97
+
98
+ export const attributesOf = (live: aclRules.ACLRule, props: AclRuleProps): AclRuleAttributes => ({
99
+ siteId: props.siteId,
100
+ aclRuleId: live.id,
101
+ action: live.action,
102
+ description: live.description,
103
+ destinationFilter: live.destinationFilter,
104
+ enabled: live.enabled,
105
+ enforcingDeviceFilter: normalizeDeviceFilter(live.enforcingDeviceFilter),
106
+ index: live.index,
107
+ name: live.name,
108
+ sourceFilter: live.sourceFilter,
109
+ type: live.type,
110
+ protocolFilter: normalizeProtocolFilter(live.protocolFilter),
111
+ networkIdFilter: live.networkIdFilter,
112
+ metadataOrigin: live.metadata.origin,
113
+ });
114
+
115
+ /**
116
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
117
+ * needs so its plan is noop"). A later import script feeds `getAclRule`'s own response straight in
118
+ * and writes the result as `aclRule('<id>', declareAclRule(live, siteId))` — no field is invented
119
+ * or defaulted beyond what `attributesOf` already reports, so `matches(attributesOf(live, props),
120
+ * declareAclRule(live, siteId))` is `true` by construction (`acl-rule.test.ts` proves it).
121
+ */
122
+ export const declareAclRule = (live: aclRules.ACLRule, siteId: string): AclRuleProps => ({
123
+ siteId,
124
+ aclRuleId: live.id,
125
+ action: live.action,
126
+ description: live.description,
127
+ destinationFilter: live.destinationFilter,
128
+ enabled: live.enabled,
129
+ enforcingDeviceFilter: normalizeDeviceFilter(live.enforcingDeviceFilter),
130
+ name: live.name,
131
+ sourceFilter: live.sourceFilter,
132
+ type: live.type,
133
+ protocolFilter: normalizeProtocolFilter(live.protocolFilter),
134
+ networkIdFilter: live.networkIdFilter,
135
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `Unifi.AclRuleOrdering`'s wire shape — ONE resource per site (`getAclRuleOrdering`'s own scope,
3
+ * `GET /v1/sites/{siteId}/acl-rules/ordering`), not per rule. Compare with `firewall-zone-form.ts`'s
4
+ * `networkIds`, which is a genuine SET: this file's `orderedAclRuleIds` is the opposite case.
5
+ *
6
+ * ⛔ T5 — `orderedAclRuleIds` IS A SEQUENCE, NEVER RUN THROUGH `sortedSet`. The endpoint's own name
7
+ * (`updateAclRuleOrdering`) and its field's own vendor comment on `ACLRule.index` ("Lower index
8
+ * has higher priority") both say this list's POSITION is the entire value it carries — rule
9
+ * priority. `firewall-zone-form.ts`'s `sortedSet`/`network-form.ts`'s three set normalizers all
10
+ * exist to stop the OPPOSITE mistake (treating a set as if order mattered); applying the same
11
+ * fix here would silently discard the one thing this resource represents. `drift.ts`'s
12
+ * `defaultEqual` (`deepEqual`) already compares arrays element-by-element without sorting them
13
+ * (its header: "sorts object KEYS but NOT ARRAY ELEMENTS") — so leaving this field un-normalized
14
+ * is what makes the comparison order-sensitive, not an oversight to "fix" later.
15
+ *
16
+ * ★ `makeDriftOf` (`drift.ts`), NO CUSTOM `equal` — same framework every other family's
17
+ * `matches`/`driftOf` share (MEDIUM-4). Its default `deepEqual` is ALREADY order-sensitive for
18
+ * arrays (see the paragraph above), so the absence of a custom `equal` here, unlike
19
+ * `firewall-zone-form.ts`'s `networkIds` entry, is itself the "never sortedSet" rule in force —
20
+ * not an oversight.
21
+ */
22
+ import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
23
+ import { makeDriftOf } from './drift.ts';
24
+
25
+ export interface AclRuleOrderingProps {
26
+ siteId: string;
27
+ /** Rule IDs in priority order, highest priority first — never compared as a set (see header). */
28
+ orderedAclRuleIds: string[];
29
+ }
30
+
31
+ export interface AclRuleOrderingAttributes {
32
+ siteId: string;
33
+ orderedAclRuleIds: string[];
34
+ }
35
+
36
+ export const attributesOf = (
37
+ live: aclRules.ACLRuleOrdering,
38
+ props: AclRuleOrderingProps,
39
+ ): AclRuleOrderingAttributes => ({
40
+ siteId: props.siteId,
41
+ orderedAclRuleIds: live.orderedAclRuleIds,
42
+ });
43
+
44
+ /**
45
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
46
+ * needs so its plan is noop"). A later import script feeds `getAclRuleOrdering`'s own response
47
+ * straight in and writes the result as `aclRuleOrdering('site-1', declareAclRuleOrdering(live,
48
+ * siteId))` — `matches(attributesOf(live, props), declareAclRuleOrdering(live, siteId))` is `true`
49
+ * by construction (`acl-rule-ordering.test.ts` proves it).
50
+ */
51
+ export const declareAclRuleOrdering = (
52
+ live: aclRules.ACLRuleOrdering,
53
+ siteId: string,
54
+ ): AclRuleOrderingProps => ({
55
+ siteId,
56
+ orderedAclRuleIds: live.orderedAclRuleIds,
57
+ });
58
+
59
+ const fieldDrift = makeDriftOf<AclRuleOrderingAttributes, AclRuleOrderingProps>([
60
+ {
61
+ field: 'orderedAclRuleIds',
62
+ live: (a) => a.orderedAclRuleIds,
63
+ declared: (p) => p.orderedAclRuleIds,
64
+ },
65
+ ]);
66
+
67
+ export const matches = (
68
+ attributes: AclRuleOrderingAttributes,
69
+ props: AclRuleOrderingProps,
70
+ ): boolean => fieldDrift(attributes, props).length === 0;
71
+
72
+ /**
73
+ * Per-field live-vs-declared drift straight from one live read — the task's own API
74
+ * (`driftOf(live, props)`). Only one field exists, so this either reports nothing or the whole
75
+ * ordered pair, never a per-position breakdown -- a future consumer wanting "which rule moved"
76
+ * would need to diff the two arrays itself.
77
+ */
78
+ export const driftOf = (live: aclRules.ACLRuleOrdering, props: AclRuleOrderingProps) =>
79
+ fieldDrift(attributesOf(live, props), props);
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `Unifi.AclRuleOrdering`'s `spec` against a fake UniFi Network API — mirrors `acl-rule.test.ts`.
3
+ * The one thing this file exists to prove that no other family test does: T5's "order IS the
4
+ * value, never `sortedSet`" rule actually holds for `orderedAclRuleIds`.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
8
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
9
+ import * as Effect from 'effect/Effect';
10
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
+ import { attributesOf, driftOf, matches } from './acl-rule-ordering-form.ts';
12
+ import { type AclRuleOrderingProps, declareAclRuleOrdering, spec } from './acl-rule-ordering.ts';
13
+ import { unifiOperations } from './resource.ts';
14
+
15
+ const ORDERING_PATH = '/proxy/network/integration/v1/sites/site-1/acl-rules/ordering';
16
+
17
+ const liveOrdering = (
18
+ overrides: Partial<aclRules.ACLRuleOrdering> = {},
19
+ ): aclRules.ACLRuleOrdering => ({
20
+ orderedAclRuleIds: ['rule-1', 'rule-2', 'rule-3'],
21
+ ...overrides,
22
+ });
23
+
24
+ const PROPS: AclRuleOrderingProps = {
25
+ siteId: 'site-1',
26
+ orderedAclRuleIds: ['rule-1', 'rule-2', 'rule-3'],
27
+ };
28
+
29
+ describe('Unifi.AclRuleOrdering spec.fetchLive', () => {
30
+ test('a real GET response decodes into the typed live object', async () => {
31
+ const fake = fakeUnifi((method, url) =>
32
+ method === 'GET' && url.pathname === ORDERING_PATH
33
+ ? Response.json(liveOrdering())
34
+ : fakeFailure(400, 'unexpected request'),
35
+ );
36
+ const live = await Effect.runPromise(
37
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
38
+ );
39
+ expect(live?.orderedAclRuleIds).toEqual(['rule-1', 'rule-2', 'rule-3']);
40
+ expect(fake.seen).toEqual([{ method: 'GET', path: ORDERING_PATH }]);
41
+ });
42
+
43
+ test('a genuine 404 folds to undefined', async () => {
44
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
45
+ const live = await Effect.runPromise(
46
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
47
+ );
48
+ expect(live).toBeUndefined();
49
+ });
50
+
51
+ test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
52
+ // ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
53
+ // asserts the FAILURE, not the retry schedule. Regression for red-team Important finding 2:
54
+ // a blanket `Effect.orElseSucceed(() => undefined)` fold would pass every test above too.
55
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
56
+ const failure = await Effect.runPromise(
57
+ Effect.flip(
58
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
59
+ ),
60
+ );
61
+ expect(failure._tag).toBe('InternalServerError');
62
+ });
63
+ });
64
+
65
+ describe('declareAclRuleOrdering -- the declaration renderer', () => {
66
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
67
+ const live = liveOrdering({ orderedAclRuleIds: ['rule-9', 'rule-2'] });
68
+ const declared = declareAclRuleOrdering(live, 'site-1');
69
+ const attrs = attributesOf(live, declared);
70
+ expect(matches(attrs, declared)).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe('T5 -- order IS the value, never sortedSet', () => {
75
+ test('an identical list is a noop', () => {
76
+ expect(driftOf(liveOrdering(), PROPS)).toEqual([]);
77
+ expect(matches(attributesOf(liveOrdering(), PROPS), PROPS)).toBe(true);
78
+ });
79
+
80
+ test('the SAME rule ids in a DIFFERENT order is real drift, not a noop', () => {
81
+ // The one behavior `firewall-zone-form.ts`'s `networkIds` and this file must NOT share:
82
+ // there, a reorder is a noop (a set); here, it is the whole point of the resource.
83
+ const live = liveOrdering({ orderedAclRuleIds: ['rule-2', 'rule-1', 'rule-3'] });
84
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
85
+ expect(driftOf(live, PROPS)).toEqual([
86
+ {
87
+ field: 'orderedAclRuleIds',
88
+ live: ['rule-2', 'rule-1', 'rule-3'],
89
+ declared: ['rule-1', 'rule-2', 'rule-3'],
90
+ },
91
+ ]);
92
+ });
93
+
94
+ test('a genuinely different rule set (not just reordered) is also drift', () => {
95
+ const live = liveOrdering({ orderedAclRuleIds: ['rule-1', 'rule-4'] });
96
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('Unifi.AclRuleOrdering write paths never reach the vendor API', () => {
101
+ const ops = unifiOperations(spec);
102
+
103
+ test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
104
+ const fake = fakeUnifi((method, url) =>
105
+ method === 'GET' && url.pathname === ORDERING_PATH
106
+ ? Response.json(liveOrdering())
107
+ : fakeFailure(400, 'unexpected request'),
108
+ );
109
+ const exit = await Effect.runPromiseExit(
110
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
111
+ );
112
+ expect(exit._tag).toBe('Success');
113
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
114
+ });
115
+
116
+ test('reconcile on drift (a reorder) refuses instead of PUTting the whole list', async () => {
117
+ const fake = fakeUnifi((method, url) =>
118
+ method === 'GET' && url.pathname === ORDERING_PATH
119
+ ? Response.json(liveOrdering({ orderedAclRuleIds: ['rule-3', 'rule-2', 'rule-1'] }))
120
+ : fakeFailure(400, 'unexpected request'),
121
+ );
122
+ const exit = await Effect.runPromiseExit(
123
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
124
+ );
125
+ expect(exit._tag).toBe('Failure');
126
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
127
+ });
128
+
129
+ test('delete always refuses -- no DELETE is ever sent', async () => {
130
+ const fake = fakeUnifi((method, url) =>
131
+ method === 'GET' && url.pathname === ORDERING_PATH
132
+ ? Response.json(liveOrdering())
133
+ : fakeFailure(400, 'unexpected request'),
134
+ );
135
+ const exit = await Effect.runPromiseExit(
136
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
137
+ );
138
+ expect(exit._tag).toBe('Failure');
139
+ expect(fake.seen).toEqual([]);
140
+ });
141
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `Unifi.AclRuleOrdering` — the ONE ordered list of user-defined ACL rule IDs for a site
3
+ * (`getAclRuleOrdering`/`updateAclRuleOrdering`, `GET`/`PUT /v1/sites/{siteId}/acl-rules/ordering`),
4
+ * READ-ONLY (`policy.ts`). T5's per-family variant of the ordering trap `docs/unifi-api-notes.md`
5
+ * warns about for `Unifi.FirewallPolicyOrdering` (`firewall-policy-ordering.ts`, keyed per zone
6
+ * pair rather than site-wide — see its own header for why): the endpoint replaces the WHOLE list
7
+ * on a write, so a partial or resorted body would drop or reorder every OTHER rule too.
8
+ * This family never writes at all, but its `matches`/`driftOf` (`acl-rule-ordering-form.ts`) are
9
+ * still order-preserving, never `sortedSet`, so a future write path inherits a correct comparison
10
+ * rather than one that has to be fixed alongside the first `update`.
11
+ *
12
+ * ★ ONE OBJECT PER SITE, NOT PER RULE — unlike `acl-rule.ts`, there is no per-object id in the
13
+ * wire response at all (`ACLRuleOrdering` is just `{ orderedAclRuleIds }`); the resource's own
14
+ * `id` (the stack-chosen string passed to `aclRuleOrdering(id, props)`) is a label the DECLARER
15
+ * picks, same as `firewallZone`/`network`, not something read back from the vendor.
16
+ */
17
+ import { Resource } from 'alchemy';
18
+ import { adopt } from 'alchemy/AdoptPolicy';
19
+ import * as Provider from 'alchemy/Provider';
20
+ import * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
21
+ import * as Effect from 'effect/Effect';
22
+ import {
23
+ type AclRuleOrderingAttributes,
24
+ type AclRuleOrderingProps,
25
+ attributesOf,
26
+ matches,
27
+ } from './acl-rule-ordering-form.ts';
28
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
29
+ import { unifiHandlers } from './resource.ts';
30
+
31
+ export type { AclRuleOrderingAttributes, AclRuleOrderingProps } from './acl-rule-ordering-form.ts';
32
+ export { declareAclRuleOrdering, driftOf } from './acl-rule-ordering-form.ts';
33
+
34
+ export interface UnifiAclRuleOrdering extends Resource<
35
+ 'Unifi.AclRuleOrdering',
36
+ AclRuleOrderingProps,
37
+ AclRuleOrderingAttributes,
38
+ never,
39
+ UnifiRequirements
40
+ > {}
41
+
42
+ export const UnifiAclRuleOrdering = Resource<UnifiAclRuleOrdering>('Unifi.AclRuleOrdering', {
43
+ defaultRemovalPolicy: 'retain',
44
+ });
45
+
46
+ /** `aclRuleOrdering('site-1', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
47
+ export const aclRuleOrdering = (id: string, props: AclRuleOrderingProps) =>
48
+ UnifiAclRuleOrdering(id, props).pipe(adopt(true));
49
+
50
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
51
+ export const spec: UnifiSpec<
52
+ AclRuleOrderingProps,
53
+ aclRules.ACLRuleOrdering,
54
+ AclRuleOrderingAttributes,
55
+ aclRules.GetAclRuleOrderingError
56
+ > = {
57
+ type: 'Unifi.AclRuleOrdering',
58
+ describe: (props) => `sites/${props.siteId}/acl-rules/ordering`,
59
+ fetchLive: (props) =>
60
+ aclRules
61
+ .getAclRuleOrdering({ siteId: props.siteId })
62
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
63
+ attributes: attributesOf,
64
+ matches,
65
+ };
66
+
67
+ export const handlers = unifiHandlers(spec);
68
+
69
+ export const UnifiAclRuleOrderingProvider = () =>
70
+ Provider.effect(UnifiAclRuleOrdering, Effect.succeed(UnifiAclRuleOrdering.Provider.of(handlers)));
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Unifi.AclRule`'s `spec` against a fake UniFi Network API — mirrors `network.test.ts`/
3
+ * `firewall-zone.test.ts`. `driftOf`/`matches` coverage lives here too, same as
4
+ * `dns-policy.test.ts`, not split into its own file (see that file's own header).
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import type * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
8
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
9
+ import * as Effect from 'effect/Effect';
10
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
+ import { attributesOf } from './acl-rule-form.ts';
12
+ import { driftOf, matches } from './acl-rule-drift.ts';
13
+ import { type AclRuleProps, declareAclRule, spec } from './acl-rule.ts';
14
+ import { unifiOperations } from './resource.ts';
15
+
16
+ const RULE_PATH = '/proxy/network/integration/v1/sites/site-1/acl-rules/rule-1';
17
+
18
+ const liveAclRule = (overrides: Partial<aclRules.ACLRule> = {}): aclRules.ACLRule => ({
19
+ action: 'BLOCK',
20
+ enabled: true,
21
+ id: 'rule-1',
22
+ index: 3,
23
+ metadata: { origin: 'USER' },
24
+ name: 'Block IoT to WAN',
25
+ // ★ `IPV4` is the real `ACL rule` discriminator value (`type`: `IPV4`|`MAC`, `docs/unifi-acl-
26
+ // rule.md`) -- a prior fixture used the invented `NETWORK_TO_INTERNET`, corrected 2026-09-26.
27
+ type: 'IPV4',
28
+ protocolFilter: ['TCP', 'UDP'],
29
+ // ★ `DEVICES` is the real `ACL rule device filter` discriminator value; the vendor has no
30
+ // "ALL_SWITCHES" variant -- omitting the field entirely means "all switches" per its own
31
+ // description ("When null, the rule will be provisioned to all switches on the site").
32
+ enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1'] },
33
+ ...overrides,
34
+ });
35
+
36
+ const PROPS: AclRuleProps = {
37
+ siteId: 'site-1',
38
+ aclRuleId: 'rule-1',
39
+ action: 'BLOCK',
40
+ enabled: true,
41
+ name: 'Block IoT to WAN',
42
+ type: 'IPV4',
43
+ protocolFilter: ['TCP', 'UDP'],
44
+ enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1'] },
45
+ };
46
+
47
+ describe('Unifi.AclRule spec.fetchLive', () => {
48
+ test('a real GET response decodes into the typed live object', async () => {
49
+ const fake = fakeUnifi((method, url) =>
50
+ method === 'GET' && url.pathname === RULE_PATH
51
+ ? Response.json(liveAclRule())
52
+ : fakeFailure(400, 'unexpected request'),
53
+ );
54
+ const live = await Effect.runPromise(
55
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
56
+ );
57
+ expect(live?.id).toBe('rule-1');
58
+ expect(fake.seen).toEqual([{ method: 'GET', path: RULE_PATH }]);
59
+ });
60
+
61
+ test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
62
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
63
+ const live = await Effect.runPromise(
64
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
65
+ );
66
+ expect(live).toBeUndefined();
67
+ });
68
+
69
+ test('a 500 fails loudly -- only a genuine not-found may mean absent (T14)', async () => {
70
+ // ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
71
+ // asserts the FAILURE, not the retry schedule. Regression for red-team Important finding 2:
72
+ // a blanket `Effect.orElseSucceed(() => undefined)` fold would pass every test above too.
73
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
74
+ const failure = await Effect.runPromise(
75
+ Effect.flip(
76
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
77
+ ),
78
+ );
79
+ expect(failure._tag).toBe('InternalServerError');
80
+ });
81
+ });
82
+
83
+ describe('declareAclRule -- the declaration renderer', () => {
84
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
85
+ const live = liveAclRule({ protocolFilter: ['UDP', 'TCP'], index: 9 });
86
+ const declared = declareAclRule(live, 'site-1');
87
+ const attrs = attributesOf(live, declared);
88
+ expect(matches(attrs, declared)).toBe(true);
89
+ expect(driftOf(live, declared)).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('driftOf -- B6 field-level drift, straight from one live read', () => {
94
+ test('a real name change reports its own field, live and declared', () => {
95
+ const live = liveAclRule({ name: 'Block cameras from WAN' });
96
+ expect(driftOf(live, PROPS)).toEqual([
97
+ { field: 'name', live: 'Block cameras from WAN', declared: 'Block IoT to WAN' },
98
+ ]);
99
+ });
100
+
101
+ test('protocolFilter in a different order is a noop -- it is a SET, not a sequence', () => {
102
+ const live = liveAclRule({ protocolFilter: ['UDP', 'TCP'] });
103
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(true);
104
+ expect(driftOf(live, PROPS)).toEqual([]);
105
+ });
106
+
107
+ test('the DECLARED side of protocolFilter is also normalized, not just live (MEDIUM-4 mutant)', () => {
108
+ const live = liveAclRule({ protocolFilter: ['TCP', 'UDP'] });
109
+ const props: AclRuleProps = { ...PROPS, protocolFilter: ['UDP', 'TCP'] };
110
+ expect(matches(attributesOf(live, props), props)).toBe(true);
111
+ expect(driftOf(live, props)).toEqual([]);
112
+ });
113
+
114
+ test('a genuine protocolFilter difference is caught', () => {
115
+ const live = liveAclRule({ protocolFilter: ['TCP'] });
116
+ expect(driftOf(live, PROPS)).toEqual([
117
+ { field: 'protocolFilter', live: ['TCP'], declared: ['TCP', 'UDP'] },
118
+ ]);
119
+ });
120
+
121
+ test('enforcingDeviceFilter.deviceIds in a different order is a noop -- also a SET', () => {
122
+ const live = liveAclRule({
123
+ enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-2', 'sw-1'] },
124
+ });
125
+ const props: AclRuleProps = {
126
+ ...PROPS,
127
+ enforcingDeviceFilter: { type: 'DEVICES', deviceIds: ['sw-1', 'sw-2'] },
128
+ };
129
+ expect(matches(attributesOf(live, props), props)).toBe(true);
130
+ expect(driftOf(live, props)).toEqual([]);
131
+ });
132
+
133
+ test('index is never compared -- it is attributes-only (deprecated for writes, owned by ordering)', () => {
134
+ const live = liveAclRule({ index: 41 });
135
+ expect(matches(attributesOf(live, PROPS), PROPS)).toBe(true);
136
+ expect(attributesOf(live, PROPS).index).toBe(41);
137
+ });
138
+
139
+ test('sourceFilter/destinationFilter stay opaque -- a real difference is still caught', () => {
140
+ // `NETWORKS` + `networkIds` (plural, an array) is the real `IntegrationIpAclRuleNetworkEndpoint
141
+ // FilterDto` shape -- a prior fixture used the invented singular `NETWORK`/`networkId`,
142
+ // corrected 2026-09-26. `sourceFilter` is `unknown` either way, so this is a fixture-realism fix
143
+ // only, not a behavior change.
144
+ const live = liveAclRule({ sourceFilter: { type: 'NETWORKS', networkIds: ['net-1'] } });
145
+ expect(driftOf(live, PROPS)).toEqual([
146
+ {
147
+ field: 'sourceFilter',
148
+ live: { type: 'NETWORKS', networkIds: ['net-1'] },
149
+ declared: undefined,
150
+ },
151
+ ]);
152
+ });
153
+ });
154
+
155
+ describe('Unifi.AclRule write paths never reach the vendor API', () => {
156
+ const ops = unifiOperations(spec);
157
+
158
+ test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
159
+ const fake = fakeUnifi((method, url) =>
160
+ method === 'GET' && url.pathname === RULE_PATH
161
+ ? Response.json(liveAclRule())
162
+ : fakeFailure(400, 'unexpected request'),
163
+ );
164
+ const exit = await Effect.runPromiseExit(
165
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
166
+ );
167
+ expect(exit._tag).toBe('Success');
168
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
169
+ });
170
+
171
+ test('delete always refuses -- no DELETE is ever sent', async () => {
172
+ const fake = fakeUnifi((method, url) =>
173
+ method === 'GET' && url.pathname === RULE_PATH
174
+ ? Response.json(liveAclRule())
175
+ : fakeFailure(400, 'unexpected request'),
176
+ );
177
+ const exit = await Effect.runPromiseExit(
178
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
179
+ );
180
+ expect(exit._tag).toBe('Failure');
181
+ expect(fake.seen).toEqual([]);
182
+ });
183
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `Unifi.AclRule` — one user-defined ACL rule on a UniFi Network site, READ-ONLY (`policy.ts`).
3
+ * `Unifi.AclRuleOrdering` (`acl-rule-ordering.ts`) is the sibling resource for rule ORDER (T5) —
4
+ * one per site, never this file's concern.
5
+ *
6
+ * ★ T11 GATE — see `dns-policy.ts`'s own header for the full closure-diff record: `Access Control
7
+ * (ACL Rules)` (7 ops, including `getAclRuleOrdering`/`updateAclRuleOrdering`) is
8
+ * byte-identical between 10.4.57 and 10.6.97, and unreachable from any of the 14 schemas that DID
9
+ * change. Full breakdown: `docs/unifi-acl-rule.md`.
10
+ */
11
+ import { Resource } from 'alchemy';
12
+ import { adopt } from 'alchemy/AdoptPolicy';
13
+ import * as Provider from 'alchemy/Provider';
14
+ import * as aclRules from '@distilled.cloud/unifi-network/access_control_acl_rules';
15
+ import * as Effect from 'effect/Effect';
16
+ import { type AclRuleAttributes, type AclRuleProps, attributesOf } from './acl-rule-form.ts';
17
+ import { matches } from './acl-rule-drift.ts';
18
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
19
+ import { unifiHandlers } from './resource.ts';
20
+
21
+ export type { AclRuleAttributes, AclRuleProps } from './acl-rule-form.ts';
22
+ export { declareAclRule } from './acl-rule-form.ts';
23
+ export { driftOf } from './acl-rule-drift.ts';
24
+
25
+ export interface UnifiAclRule extends Resource<
26
+ 'Unifi.AclRule',
27
+ AclRuleProps,
28
+ AclRuleAttributes,
29
+ never,
30
+ UnifiRequirements
31
+ > {}
32
+
33
+ export const UnifiAclRule = Resource<UnifiAclRule>('Unifi.AclRule', {
34
+ defaultRemovalPolicy: 'retain',
35
+ });
36
+
37
+ /** `aclRule('block-iot-wan', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
38
+ export const aclRule = (id: string, props: AclRuleProps) =>
39
+ UnifiAclRule(id, props).pipe(adopt(true));
40
+
41
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
42
+ export const spec: UnifiSpec<
43
+ AclRuleProps,
44
+ aclRules.ACLRule,
45
+ AclRuleAttributes,
46
+ aclRules.GetAclRuleError
47
+ > = {
48
+ type: 'Unifi.AclRule',
49
+ describe: (props) => `sites/${props.siteId}/acl-rules/${props.aclRuleId}`,
50
+ fetchLive: (props) =>
51
+ aclRules
52
+ .getAclRule({ siteId: props.siteId, aclRuleId: props.aclRuleId })
53
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
54
+ attributes: attributesOf,
55
+ matches,
56
+ };
57
+
58
+ export const handlers = unifiHandlers(spec);
59
+
60
+ export const UnifiAclRuleProvider = () =>
61
+ Provider.effect(UnifiAclRule, Effect.succeed(UnifiAclRule.Provider.of(handlers)));