@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,211 @@
1
+ /**
2
+ * `Unifi.WifiBroadcast`'s wire shape — the declarable subset of `WifiBroadcastOverview`, and how
3
+ * a live read becomes both plan attributes and a rendered declaration.
4
+ *
5
+ * ⛔ T23, THE WHOLE REASON THIS SHAPE DIFFERS FROM EVERY OTHER FAMILY HERE. Every other family's
6
+ * `*-form.ts` (`network-form.ts`, `dns-policy-form.ts`, …) types its props against the vendor's
7
+ * CREATE/UPDATE request — the fullest declarable shape, even though this stack never calls it.
8
+ * `WifiBroadcastProps`/`WifiBroadcastAttributes` do NOT: `securityConfiguration` is typed
9
+ * `wifiBroadcasts.WifiSecurityConfigurationOverview` — the PAGE OVERVIEW's own type, `{type,
10
+ * presharedKeyNetworkIds}` — never `WifiSecurityConfigurationDetailObject` (the
11
+ * `getWifiBroadcastDetails`/create/update shape, which carries `passphrase` and
12
+ * `radiusConfiguration`). There is no DECLARED field on `WifiSecurityConfigurationOverview` for
13
+ * `attributesOf`/`declareWifiBroadcast` below to name a passphrase into. See `wifi-broadcast.ts`'s
14
+ * header for why `fetchLive` can only ever produce a `WifiBroadcastOverview` in the first place
15
+ * (no get-by-id call exists for it).
16
+ *
17
+ * ⛔ THE TYPE SYSTEM ALONE DOES NOT ENFORCE THIS (red team, IMPORTANT-1, 2026-09-26). `@distilled
18
+ * .cloud/core`'s wire decode does not strip a key a schema doesn't declare (this file's next
19
+ * header block); the TS types above describe what a CORRECTLY-shaped response looks like, not
20
+ * what the decoder actually returns at runtime, so a stray key nested inside `network`,
21
+ * `hotspotConfiguration`, `broadcastingDeviceFilter`, or an element of `presharedKeyNetworkIds`
22
+ * would ride along through a naive `{...live}`/pass-through copy exactly like the top-level
23
+ * `securityConfiguration` object does. `normalizeRef`/`normalizeHotspot`/`normalizeDeviceFilter`
24
+ * below rebuild EVERY nested object field-by-field for the same reason
25
+ * `normalizeSecurityConfiguration` does — enumeration, not the type annotation, is what actually
26
+ * stops it. `wifi-broadcast-secrets.test.ts` plants a sentinel inside each nested shape
27
+ * (including one `presharedKeyNetworkIds` element) to prove it, not just the top level.
28
+ *
29
+ * ⚠️ EVERY OTHER FIELD REUSES THE SDK'S OWN OVERVIEW TYPES DIRECTLY (`WifiNetworkReference`,
30
+ * `BroadcastingDeviceFilter`, `IntegrationWifiHotspotConfigurationOverviewDto`) rather than
31
+ * redeclaring them — same reasoning `network-form.ts` gives for reusing `NetworkDHCPGuarding`/
32
+ * `NetworkIPv6Configuration` verbatim: one shape to keep in sync with the SDK's own regen.
33
+ *
34
+ * ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES, UNCONFIRMED AGAINST A LIVE CONSOLE (same caveat
35
+ * `network-form.ts`'s header gives for its own three): `broadcastingDeviceFilter.deviceIds`/
36
+ * `.deviceTagIds` and `securityConfiguration.presharedKeyNetworkIds` are membership sets by the
37
+ * vendor description's own wording ("Defines the custom scope of devices…"), not ordered
38
+ * sequences. `normalizeDeviceFilter`/`normalizeSecurityConfiguration`/`normalizeFrequencies`
39
+ * neutralize the same "spurious update, refused by a read-only reconcile" failure mode
40
+ * `network-form.ts`'s header describes, exactly like `firewall-zone-form.ts`'s `networkIds`.
41
+ */
42
+ import type * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
43
+
44
+ const sortedStrings = (values: readonly string[]): string[] => [...new Set(values)].sort();
45
+
46
+ const sortedFrequencies = (values: readonly number[]): number[] =>
47
+ [...new Set(values)].sort((a, b) => a - b);
48
+
49
+ /** Canonical sort key for a `{type, networkId?}` reference — stable regardless of vendor order. */
50
+ const refKey = (ref: wifiBroadcasts.WifiNetworkReference): string =>
51
+ `${ref.type}:${ref.networkId ?? ''}`;
52
+
53
+ /**
54
+ * Rebuilds a reference field-by-field — never returns the object as received. IMPORTANT-1: this
55
+ * runs on EVERY reference this file handles (`network`, and each `presharedKeyNetworkIds`
56
+ * element) precisely because a reference is exactly the shape (`{network: WifiNetworkReference,
57
+ * passphrase}`) the details call uses to carry a PPSK passphrase (`IntegrationWifiPresharedKeyDto`,
58
+ * SDK `wifi_broadcasts.ts`); it is the most plausible place for a stray passphrase-shaped key to
59
+ * appear on the wire if the vendor ever leaked one onto the overview response.
60
+ */
61
+ export const normalizeRef = (
62
+ ref: wifiBroadcasts.WifiNetworkReference | undefined,
63
+ ): wifiBroadcasts.WifiNetworkReference | undefined =>
64
+ ref === undefined
65
+ ? undefined
66
+ : { type: ref.type, ...(ref.networkId !== undefined ? { networkId: ref.networkId } : {}) };
67
+
68
+ const sortedRefs = (
69
+ values: readonly wifiBroadcasts.WifiNetworkReference[],
70
+ ): wifiBroadcasts.WifiNetworkReference[] => {
71
+ const byKey = new Map(
72
+ values.map(
73
+ (ref) => [refKey(ref), normalizeRef(ref) as wifiBroadcasts.WifiNetworkReference] as const,
74
+ ),
75
+ );
76
+ return [...byKey.values()].sort((a, b) => refKey(a).localeCompare(refKey(b)));
77
+ };
78
+
79
+ /**
80
+ * Rebuilds the hotspot configuration field-by-field. `IntegrationWifiHotspotConfigurationOverviewDto`
81
+ * declares only `type` (SDK `wifi_broadcasts.ts:743-753`) — a PASSPOINT/RADIUS secret would live on
82
+ * a wire key this type doesn't name, exactly the IMPORTANT-1 risk `normalizeRef` guards against.
83
+ */
84
+ export const normalizeHotspot = (
85
+ value: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined,
86
+ ): wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined =>
87
+ value === undefined ? undefined : { type: value.type };
88
+
89
+ /**
90
+ * ★ EXPORTED: `wifi-broadcast-drift.ts` reuses these exact normalizers so `matches`/`driftOf` can
91
+ * never quietly disagree about what counts as a change (MEDIUM-4, `network-drift.ts`'s header).
92
+ *
93
+ * ⛔ NO `{...value}` SPREAD (red team, IMPORTANT-1) — `BroadcastingDeviceFilter` declares exactly
94
+ * `type`/`deviceIds`/`deviceTagIds` (SDK `wifi_broadcasts.ts:125-129`); enumerating exactly those
95
+ * three is what stops a stray wire key here, the same reason `normalizeSecurityConfiguration`
96
+ * (below) never spreads its input.
97
+ */
98
+ export const normalizeDeviceFilter = (
99
+ value: wifiBroadcasts.BroadcastingDeviceFilter | undefined,
100
+ ): wifiBroadcasts.BroadcastingDeviceFilter | undefined =>
101
+ value == null
102
+ ? value
103
+ : {
104
+ type: value.type,
105
+ ...(value.deviceIds !== undefined ? { deviceIds: sortedStrings(value.deviceIds) } : {}),
106
+ ...(value.deviceTagIds !== undefined
107
+ ? { deviceTagIds: sortedStrings(value.deviceTagIds) }
108
+ : {}),
109
+ };
110
+
111
+ export const normalizeFrequencies = (value: readonly number[] | undefined): number[] | undefined =>
112
+ value === undefined ? value : sortedFrequencies(value);
113
+
114
+ /**
115
+ * ⛔ NO `{...value}` SPREAD, UNLIKE EVERY OTHER NORMALIZER IN THIS DIRECTORY (`network-form.ts`'s
116
+ * `normalizeDhcpGuarding`/`normalizeIpv6Configuration` both spread). This is the one field in
117
+ * the whole `Unifi.*` tree with a spec-confirmed passphrase risk (T23): nothing in
118
+ * `@distilled.cloud/core`'s wire key-mapping strips an object key the schema doesn't declare —
119
+ * it only renames declared ones — so a stray extra key on the WIRE `securityConfiguration`
120
+ * object (a vendor bug, or a future console version) survives `getWifiBroadcastPage`'s own
121
+ * decode untyped, and `{...value}` would carry it straight through into `Attributes`/`Props`/the
122
+ * rendered declaration. Enumerating exactly the two fields
123
+ * `WifiSecurityConfigurationOverview` declares is what actually stops that — proved by
124
+ * `wifi-broadcast-secrets.test.ts`'s sentinel test, which injects exactly this kind of stray key.
125
+ */
126
+ export const normalizeSecurityConfiguration = (
127
+ value: wifiBroadcasts.WifiSecurityConfigurationOverview,
128
+ ): wifiBroadcasts.WifiSecurityConfigurationOverview => ({
129
+ type: value.type,
130
+ ...(value.presharedKeyNetworkIds !== undefined
131
+ ? { presharedKeyNetworkIds: sortedRefs(value.presharedKeyNetworkIds) }
132
+ : {}),
133
+ });
134
+
135
+ /**
136
+ * ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE — `exactOptionalPropertyTypes`; see
137
+ * `network-form.ts`'s own header. `attributesOf`/`declareWifiBroadcast` echo a live optional
138
+ * field structurally rather than defaulting it, so the target type must admit an explicit
139
+ * `undefined`.
140
+ */
141
+ export interface WifiBroadcastProps {
142
+ siteId: string;
143
+ wifiBroadcastId: string;
144
+ name: string;
145
+ enabled: boolean;
146
+ type: string;
147
+ network?: wifiBroadcasts.WifiNetworkReference | undefined;
148
+ broadcastingDeviceFilter?: wifiBroadcasts.BroadcastingDeviceFilter | undefined;
149
+ broadcastingFrequenciesGHz?: number[] | undefined;
150
+ hotspotConfiguration?: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined;
151
+ /** ⛔ T23: the PAGE OVERVIEW's security shape ONLY — see this file's header. */
152
+ securityConfiguration: wifiBroadcasts.WifiSecurityConfigurationOverview;
153
+ }
154
+
155
+ export interface WifiBroadcastAttributes {
156
+ siteId: string;
157
+ wifiBroadcastId: string;
158
+ name: string;
159
+ enabled: boolean;
160
+ type: string;
161
+ network: wifiBroadcasts.WifiNetworkReference | undefined;
162
+ broadcastingDeviceFilter: wifiBroadcasts.BroadcastingDeviceFilter | undefined;
163
+ broadcastingFrequenciesGHz: number[] | undefined;
164
+ hotspotConfiguration: wifiBroadcasts.IntegrationWifiHotspotConfigurationOverviewDto | undefined;
165
+ securityConfiguration: wifiBroadcasts.WifiSecurityConfigurationOverview;
166
+ /** `metadata.origin` — server-derived, never declared; same split as every other family. */
167
+ metadataOrigin: string;
168
+ }
169
+
170
+ export const attributesOf = (
171
+ live: wifiBroadcasts.WifiBroadcastOverview,
172
+ props: WifiBroadcastProps,
173
+ ): WifiBroadcastAttributes => ({
174
+ siteId: props.siteId,
175
+ wifiBroadcastId: live.id,
176
+ name: live.name,
177
+ enabled: live.enabled,
178
+ type: live.type,
179
+ network: normalizeRef(live.network),
180
+ broadcastingDeviceFilter: normalizeDeviceFilter(live.broadcastingDeviceFilter),
181
+ broadcastingFrequenciesGHz: normalizeFrequencies(live.broadcastingFrequenciesGHz),
182
+ hotspotConfiguration: normalizeHotspot(live.hotspotConfiguration),
183
+ securityConfiguration: normalizeSecurityConfiguration(live.securityConfiguration),
184
+ metadataOrigin: live.metadata.origin,
185
+ });
186
+
187
+ /**
188
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
189
+ * needs so its plan is noop"). A later import script feeds `getWifiBroadcastPage`'s own per-row
190
+ * response straight in and writes the result into `alchemy.run.ts` as `wifiBroadcast('<id>',
191
+ * declareWifiBroadcast(live, siteId))` — no field is invented or defaulted here beyond what
192
+ * `attributesOf` already reports, so `matches(attributesOf(live, props), declareWifiBroadcast(live,
193
+ * siteId))` is `true` by construction (`wifi-broadcast.test.ts` proves it) for any live object
194
+ * this SDK can decode. Builds ONLY from fields `WifiBroadcastOverview` has — never a passphrase,
195
+ * which that type cannot express (see this file's header).
196
+ */
197
+ export const declareWifiBroadcast = (
198
+ live: wifiBroadcasts.WifiBroadcastOverview,
199
+ siteId: string,
200
+ ): WifiBroadcastProps => ({
201
+ siteId,
202
+ wifiBroadcastId: live.id,
203
+ name: live.name,
204
+ enabled: live.enabled,
205
+ type: live.type,
206
+ network: normalizeRef(live.network),
207
+ broadcastingDeviceFilter: normalizeDeviceFilter(live.broadcastingDeviceFilter),
208
+ broadcastingFrequenciesGHz: normalizeFrequencies(live.broadcastingFrequenciesGHz),
209
+ hotspotConfiguration: normalizeHotspot(live.hotspotConfiguration),
210
+ securityConfiguration: normalizeSecurityConfiguration(live.securityConfiguration),
211
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * T23 runtime proof for `Unifi.WifiBroadcast`, split from `wifi-broadcast.test.ts` for the same
3
+ * reason `errors-and-secrets.test.ts` is split from `network.test.ts`: keeps the security-critical
4
+ * assertions in one file a reviewer can find by name, under the house line cap.
5
+ *
6
+ * ★ WHY THE SENTINEL IS INJECTED INTO `securityConfiguration` DIRECTLY ON THE WIRE, NOT VIA A
7
+ * `WifiBroadcastOverview` TYPE. `WifiSecurityConfigurationOverview` has no `passphrase` field —
8
+ * that is the whole point of `wifi-broadcast-form.ts`'s design (see its header) — so there is no
9
+ * TYPED way to construct a fixture that carries one. The raw JSON fixture below simulates the
10
+ * one thing the type system CANNOT rule out: a vendor bug or a future console version that puts
11
+ * a passphrase-shaped key on the overview response anyway. `errors-and-secrets.test.ts` already
12
+ * proves the SDK's OWN details-call schema redacts a real `passphrase` field to `Redacted`; this
13
+ * file proves the different, narrower claim the overview path needs: even an UNDECLARED key with
14
+ * that name surviving decode never reaches this family's attributes, declaration or a forced
15
+ * error path.
16
+ */
17
+ import { describe, expect, test } from 'bun:test';
18
+ import * as Effect from 'effect/Effect';
19
+ import { fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
20
+ import { attributesOf } from './wifi-broadcast-form.ts';
21
+ import { type WifiBroadcastProps, declareWifiBroadcast, spec } from './wifi-broadcast.ts';
22
+
23
+ const PAGE_PATH = '/proxy/network/integration/v1/sites/site-1/wifi/broadcasts';
24
+
25
+ // A fabricated sentinel, not a real WiFi passphrase (kit is PUBLIC — see fake-unifi.ts).
26
+ const SENTINEL = 'sentinel-wpa-passphrase-do-not-log-me';
27
+
28
+ const PROPS: WifiBroadcastProps = {
29
+ siteId: 'site-1',
30
+ wifiBroadcastId: 'wifi-1',
31
+ name: 'Guest',
32
+ enabled: true,
33
+ type: 'STANDARD',
34
+ securityConfiguration: { type: 'WPA_PERSONAL' },
35
+ };
36
+
37
+ /**
38
+ * One base row per nested location a stray key could hide in — red team, IMPORTANT-1 (2026-09-26):
39
+ * the original version of this file only planted the sentinel on `securityConfiguration` itself
40
+ * and missed that `sortedRefs`/`network`/`hotspotConfiguration`/`broadcastingDeviceFilter` all
41
+ * passed their OWN object through unchanged (`network`/`hotspotConfiguration` assigned directly;
42
+ * `presharedKeyNetworkIds` elements returned as received; `broadcastingDeviceFilter` spread with
43
+ * `{...value}`). Each case below plants the SAME sentinel in a different one of those four spots —
44
+ * `IntegrationWifiPresharedKeyDto`'s own shape (`{network, passphrase}`, SDK `wifi_broadcasts.ts`)
45
+ * is exactly why a `presharedKeyNetworkIds` element is the most plausible leak site of the four.
46
+ */
47
+ const baseRow = {
48
+ enabled: true,
49
+ id: 'wifi-1',
50
+ metadata: { origin: 'USER' },
51
+ name: 'Guest',
52
+ type: 'STANDARD',
53
+ };
54
+
55
+ const NESTED_SENTINEL_CASES: Record<string, unknown> = {
56
+ 'top-level securityConfiguration': {
57
+ ...baseRow,
58
+ securityConfiguration: {
59
+ type: 'WPA_PERSONAL',
60
+ presharedKeyNetworkIds: [{ type: 'VLAN' }],
61
+ passphrase: SENTINEL,
62
+ },
63
+ },
64
+ 'a presharedKeyNetworkIds element (IntegrationWifiPresharedKeyDto shape)': {
65
+ ...baseRow,
66
+ securityConfiguration: {
67
+ type: 'WPA_PERSONAL',
68
+ presharedKeyNetworkIds: [{ type: 'VLAN', networkId: 'net-1', passphrase: SENTINEL }],
69
+ },
70
+ },
71
+ network: {
72
+ ...baseRow,
73
+ network: { type: 'STANDARD', networkId: 'net-1', passphrase: SENTINEL },
74
+ securityConfiguration: { type: 'WPA_PERSONAL' },
75
+ },
76
+ hotspotConfiguration: {
77
+ ...baseRow,
78
+ hotspotConfiguration: { type: 'PASSPOINT', radiusSharedSecret: SENTINEL },
79
+ securityConfiguration: { type: 'WPA_PERSONAL' },
80
+ },
81
+ broadcastingDeviceFilter: {
82
+ ...baseRow,
83
+ broadcastingDeviceFilter: {
84
+ type: 'CUSTOM',
85
+ deviceIds: ['dev-1'],
86
+ radiusSharedSecret: SENTINEL,
87
+ },
88
+ securityConfiguration: { type: 'WPA_PERSONAL' },
89
+ },
90
+ };
91
+
92
+ describe('T23 — a stray passphrase-shaped key on the wire never survives fetch -> attrs -> declare -> render', () => {
93
+ for (const [label, rawRow] of Object.entries(NESTED_SENTINEL_CASES)) {
94
+ test(`sentinel-passphrase test: ${label}`, async () => {
95
+ const fake = fakeUnifi((method, url) =>
96
+ method === 'GET' && url.pathname === PAGE_PATH
97
+ ? Response.json({ count: 1, data: [rawRow], limit: 1, offset: 0, totalCount: 1 })
98
+ : new Response('unexpected request', { status: 400 }),
99
+ );
100
+ const live = await Effect.runPromise(
101
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
102
+ );
103
+
104
+ // ⚠️ FETCH: documents the real risk, rather than assuming it away — nothing in
105
+ // `@distilled.cloud/core`'s wire key-mapping strips a key the schema does not declare (see
106
+ // `wifi-broadcast-form.ts`'s header). If this assertion ever starts failing because a future
107
+ // core/SDK regen DOES start stripping unknown keys, that is a welcome, stronger guarantee —
108
+ // not a reason this test needs to keep asserting the weaker one.
109
+ expect(JSON.stringify(live)).toContain(SENTINEL);
110
+ if (live === undefined) throw new Error('expected the fake page to decode a row');
111
+
112
+ // ATTRS: `attributesOf` rebuilds every nested object field by field (never `{...live}`,
113
+ // never assigning a nested object through unchanged).
114
+ const attrs = attributesOf(live, PROPS);
115
+ expect(JSON.stringify(attrs)).not.toContain(SENTINEL);
116
+
117
+ // DECLARE: `declareWifiBroadcast` is the same discipline, independently.
118
+ const declared = declareWifiBroadcast(live, 'site-1');
119
+ expect(JSON.stringify(declared)).not.toContain(SENTINEL);
120
+
121
+ // RENDER: the literal text a later import script would write into `alchemy.run.ts`.
122
+ const rendered = `wifiBroadcast(${JSON.stringify('wifi-1')}, ${JSON.stringify(declared)})`;
123
+ expect(rendered).not.toContain(SENTINEL);
124
+ });
125
+ }
126
+
127
+ test('forced decode-failure test: a malformed page body fails typed, without echoing the sentinel', async () => {
128
+ // A non-JSON 200 body (a genuine decode failure, not a mapped HTTP error) that happens to
129
+ // CONTAIN a sentinel value, the way a garbled proxy/debug response might. `getWifiBroadcastPage`
130
+ // has no schema to reject this against (see `wifi-broadcast-form.ts`'s header) — it decodes as
131
+ // the raw string, which fails `pageAll`'s own OWN consistency check instead (`paginate.ts`):
132
+ // the fake "page" has no `.offset`, so the walk cannot proceed and refuses to trust it.
133
+ const fake = fakeUnifi(() => new Response(`malformed body ${SENTINEL}`, { status: 200 }));
134
+ const failure = await Effect.runPromise(
135
+ Effect.flip(spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch)))),
136
+ );
137
+
138
+ expect(failure._tag).toBe('UnifiPaginationInconsistent');
139
+ // The error's OWN fields (`reason`/`detail`) are built entirely from offsets and counts
140
+ // (`paginate.ts`) — never from the raw response body — so neither they nor the `.message`
141
+ // getter built from them can ever echo the sentinel, regardless of what garbled text arrived.
142
+ expect(JSON.stringify(failure)).not.toContain(SENTINEL);
143
+ expect((failure as { message: string }).message).not.toContain(SENTINEL);
144
+ });
145
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `Unifi.WifiBroadcast`'s `spec` against a fake UniFi Network API, proving the real distilled wire
3
+ * path THROUGH `pageAll` (path assembly, JSON decode, `NotFound` folding, the pagination walk this
4
+ * family is the first resource to actually need), the declaration renderer, and — the task's own
5
+ * requirement — that no handler this family exposes ever sends a non-`GET` request.
6
+ *
7
+ * ⚠️ THE PASSPHRASE-SPECIFIC PROOFS (T23 sentinel + forced-decode-failure) LIVE IN
8
+ * `wifi-broadcast-secrets.test.ts`, SPLIT OUT — same reason `errors-and-secrets.test.ts` is its
9
+ * own file rather than folded into `network.test.ts`: keeps this file under the house line cap
10
+ * and keeps the security-critical assertions in one place a reviewer can find by name.
11
+ */
12
+ import { describe, expect, test } from 'bun:test';
13
+ import type * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
14
+ import * as Retry from '@distilled.cloud/unifi-network/Retry';
15
+ import * as Effect from 'effect/Effect';
16
+ import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
17
+ import { attributesOf } from './wifi-broadcast-form.ts';
18
+ import { matches } from './wifi-broadcast-drift.ts';
19
+ import { type WifiBroadcastProps, declareWifiBroadcast, spec } from './wifi-broadcast.ts';
20
+ import { unifiOperations } from './resource.ts';
21
+
22
+ const PAGE_PATH = '/proxy/network/integration/v1/sites/site-1/wifi/broadcasts';
23
+
24
+ const liveOverview = (
25
+ overrides: Partial<wifiBroadcasts.WifiBroadcastOverview> = {},
26
+ ): wifiBroadcasts.WifiBroadcastOverview => ({
27
+ enabled: true,
28
+ id: 'wifi-1',
29
+ metadata: { origin: 'USER' },
30
+ name: 'Guest',
31
+ type: 'STANDARD',
32
+ securityConfiguration: { type: 'WPA_PERSONAL', presharedKeyNetworkIds: [{ type: 'VLAN' }] },
33
+ ...overrides,
34
+ });
35
+
36
+ const PROPS: WifiBroadcastProps = {
37
+ siteId: 'site-1',
38
+ wifiBroadcastId: 'wifi-1',
39
+ name: 'Guest',
40
+ enabled: true,
41
+ type: 'STANDARD',
42
+ securityConfiguration: { type: 'WPA_PERSONAL', presharedKeyNetworkIds: [{ type: 'VLAN' }] },
43
+ };
44
+
45
+ /** One page's worth of the shared list envelope every UniFi list op answers. `total` is the
46
+ * FULL row count across every page, never just this page's own `rows.length` -- pageAll stops
47
+ * once `offset` reaches it (`paginate.ts`), so getting this wrong silently truncates the walk. */
48
+ const pageResponse = (
49
+ rows: readonly wifiBroadcasts.WifiBroadcastOverview[],
50
+ offset: number,
51
+ total = rows.length,
52
+ ) =>
53
+ Response.json({
54
+ count: rows.length,
55
+ data: rows,
56
+ limit: rows.length || 1,
57
+ offset,
58
+ totalCount: total,
59
+ });
60
+
61
+ describe('Unifi.WifiBroadcast spec.fetchLive', () => {
62
+ test('a single-page GET response decodes and finds the matching row', async () => {
63
+ const fake = fakeUnifi((method, url) =>
64
+ method === 'GET' && url.pathname === PAGE_PATH
65
+ ? pageResponse([liveOverview()], 0)
66
+ : fakeFailure(400, 'unexpected request'),
67
+ );
68
+ const live = await Effect.runPromise(
69
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
70
+ );
71
+ expect(live?.id).toBe('wifi-1');
72
+ expect(fake.seen).toEqual([{ method: 'GET', path: `${PAGE_PATH}?offset=0` }]);
73
+ });
74
+
75
+ test('the row can be on a LATER page -- pageAll actually walks (T9/B0b)', async () => {
76
+ const rows = [liveOverview({ id: 'wifi-0', name: 'Other' }), liveOverview({ id: 'wifi-1' })];
77
+ const fake = fakeUnifi((method, url) => {
78
+ if (method !== 'GET' || url.pathname !== PAGE_PATH) return fakeFailure(400, 'unexpected');
79
+ const offset = Number(url.searchParams.get('offset') ?? '0');
80
+ return pageResponse(rows.slice(offset, offset + 1), offset, rows.length);
81
+ });
82
+ const live = await Effect.runPromise(
83
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
84
+ );
85
+ expect(live?.id).toBe('wifi-1');
86
+ expect(fake.seen.length).toBeGreaterThan(1);
87
+ });
88
+
89
+ test('present rows but no matching id folds to undefined -- no separate "not found" signal', async () => {
90
+ const fake = fakeUnifi(() => pageResponse([liveOverview({ id: 'someone-else' })], 0));
91
+ const live = await Effect.runPromise(
92
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
93
+ );
94
+ expect(live).toBeUndefined();
95
+ });
96
+
97
+ test('a genuine 404 (site not found) folds to undefined', async () => {
98
+ const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
99
+ const live = await Effect.runPromise(
100
+ spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
101
+ );
102
+ expect(live).toBeUndefined();
103
+ });
104
+
105
+ test('a 500 fails loudly -- only a genuine not-found may mean absent', async () => {
106
+ // ⚠️ `Retry.none`: the default policy retries a 500 indefinitely with backoff -- this test
107
+ // asserts the FAILURE, not the retry schedule (same reason `network.test.ts`'s 500 test needs it).
108
+ const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
109
+ const failure = await Effect.runPromise(
110
+ Effect.flip(
111
+ spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
112
+ ),
113
+ );
114
+ expect(failure._tag).toBe('InternalServerError');
115
+ });
116
+ });
117
+
118
+ describe('declareWifiBroadcast -- the declaration renderer', () => {
119
+ test('its output matches attributesOf(live) by construction, for any live object', () => {
120
+ const live = liveOverview({ name: 'Employees', enabled: false });
121
+ const declared = declareWifiBroadcast(live, 'site-1');
122
+ const attrs = attributesOf(live, declared);
123
+ expect(matches(attrs, declared)).toBe(true);
124
+ });
125
+ });
126
+
127
+ describe('Unifi.WifiBroadcast write paths never reach the vendor API', () => {
128
+ const ops = unifiOperations(spec);
129
+
130
+ test('reconcile on an exact-match adoption (H6) sends only GETs', async () => {
131
+ const fake = fakeUnifi((method, url) =>
132
+ method === 'GET' && url.pathname === PAGE_PATH
133
+ ? pageResponse([liveOverview()], 0)
134
+ : fakeFailure(400, 'unexpected request'),
135
+ );
136
+ const exit = await Effect.runPromiseExit(
137
+ ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
138
+ );
139
+ expect(exit._tag).toBe('Success');
140
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
141
+ });
142
+
143
+ test('delete always refuses -- no DELETE is ever sent', async () => {
144
+ const fake = fakeUnifi((method, url) =>
145
+ method === 'GET' && url.pathname === PAGE_PATH
146
+ ? pageResponse([liveOverview()], 0)
147
+ : fakeFailure(400, 'unexpected request'),
148
+ );
149
+ const exit = await Effect.runPromiseExit(
150
+ ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
151
+ );
152
+ expect(exit._tag).toBe('Failure');
153
+ expect(fake.seen).toEqual([]);
154
+ });
155
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `Unifi.WifiBroadcast` — one WiFi network (SSID) broadcast on a UniFi Network site, READ-ONLY
3
+ * (`policy.ts`).
4
+ *
5
+ * ⛔ T23, THE REASON THIS FAMILY NEVER CALLS `getWifiBroadcastDetails`. That single-object GET
6
+ * returns `WifiBroadcastDetails`, whose `securityConfiguration`
7
+ * (`WifiSecurityConfigurationDetailObject`) carries the WPA passphrase AND every PPSK entry's
8
+ * passphrase — the pinned spec has NO `writeOnly` flag on either (verified: 4 `passphrase`
9
+ * props, 0 `writeOnly`; A2 already redacts them to `Redacted.Redacted<string>` at the SDK layer,
10
+ * `errors-and-secrets.test.ts`). This family goes further and never fetches that shape AT ALL:
11
+ * the only call below is `getWifiBroadcastPage`, which answers `WifiBroadcastOverview` instead —
12
+ * whose own `securityConfiguration` (`WifiSecurityConfigurationOverview`) is `{type,
13
+ * presharedKeyNetworkIds}`. No passphrase field exists in that type, so `WifiBroadcastProps`/
14
+ * `Attributes` (`wifi-broadcast-form.ts`) cannot carry one even by a future mistake.
15
+ *
16
+ * ⚠️ NO GET-BY-ID CALL EXISTS FOR THE OVERVIEW SHAPE — unlike every other family in this
17
+ * directory (`getNetworkDetails`, `getFirewallZone`, `getDnsPolicy`, `getAclRule`),
18
+ * `wifi_broadcasts.ts` only has `getWifiBroadcastPage` (list) and `getWifiBroadcastDetails`
19
+ * (single, passphrase-bearing — forbidden above). `fetchLive` below walks every page with B0b's
20
+ * `pageAll` and finds the one row matching `wifiBroadcastId` — the first real resource-level
21
+ * consumer of that pager (previously exercised only by `paginate.test.ts` in isolation).
22
+ *
23
+ * ⚠️ THE `NotFound` CATCH BELOW IS FOR THE WHOLE WALK, NOT JUST THE FIRST PAGE CALL (red team,
24
+ * MINOR-4, 2026-09-26 — corrected from an earlier claim that it only applied to the first call).
25
+ * `pageAll` makes its sequential page calls inside one Effect pipeline, and `catchTag` here wraps
26
+ * that entire pipeline; a 404 on ANY page of the walk — not only the first — folds to `undefined`
27
+ * and discards whatever rows the walk had already collected. In practice this means "the site
28
+ * vanished partway through listing its broadcasts" is indistinguishable from "the site was never
29
+ * there" and from "the target id just isn't among the collected rows" — none of these three cases
30
+ * has a separate vendor signal, and no other family's `fetchLive` distinguishes its own analogous
31
+ * cases either. Read-only reporting treats all three the same conservative way (absent, not an
32
+ * error); a write path would need to tell them apart.
33
+ */
34
+ import { Resource } from 'alchemy';
35
+ import { adopt } from 'alchemy/AdoptPolicy';
36
+ import * as Provider from 'alchemy/Provider';
37
+ import * as wifiBroadcasts from '@distilled.cloud/unifi-network/wifi_broadcasts';
38
+ import * as Effect from 'effect/Effect';
39
+ import {
40
+ type WifiBroadcastAttributes,
41
+ type WifiBroadcastProps,
42
+ attributesOf,
43
+ } from './wifi-broadcast-form.ts';
44
+ import { matches } from './wifi-broadcast-drift.ts';
45
+ import { type UnifiPaginationInconsistent, pageAll } from './paginate.ts';
46
+ import type { UnifiRequirements, UnifiSpec } from './resource.ts';
47
+ import { unifiHandlers } from './resource.ts';
48
+
49
+ export type { WifiBroadcastAttributes, WifiBroadcastProps } from './wifi-broadcast-form.ts';
50
+ export { declareWifiBroadcast } from './wifi-broadcast-form.ts';
51
+ export { driftOf } from './wifi-broadcast-drift.ts';
52
+
53
+ export interface UnifiWifiBroadcast extends Resource<
54
+ 'Unifi.WifiBroadcast',
55
+ WifiBroadcastProps,
56
+ WifiBroadcastAttributes,
57
+ never,
58
+ UnifiRequirements
59
+ > {}
60
+
61
+ export const UnifiWifiBroadcast = Resource<UnifiWifiBroadcast>('Unifi.WifiBroadcast', {
62
+ defaultRemovalPolicy: 'retain',
63
+ });
64
+
65
+ /** `wifiBroadcast('guest', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
66
+ export const wifiBroadcast = (id: string, props: WifiBroadcastProps) =>
67
+ UnifiWifiBroadcast(id, props).pipe(adopt(true));
68
+
69
+ /** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
70
+ export const spec: UnifiSpec<
71
+ WifiBroadcastProps,
72
+ wifiBroadcasts.WifiBroadcastOverview,
73
+ WifiBroadcastAttributes,
74
+ wifiBroadcasts.GetWifiBroadcastPageError | UnifiPaginationInconsistent
75
+ > = {
76
+ type: 'Unifi.WifiBroadcast',
77
+ describe: (props) => `sites/${props.siteId}/wifi/broadcasts/${props.wifiBroadcastId}`,
78
+ fetchLive: (props) =>
79
+ pageAll(wifiBroadcasts.getWifiBroadcastPage, { siteId: props.siteId }).pipe(
80
+ Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
81
+ Effect.map((rows) => rows?.find((row) => row.id === props.wifiBroadcastId)),
82
+ ),
83
+ attributes: attributesOf,
84
+ matches,
85
+ };
86
+
87
+ export const handlers = unifiHandlers(spec);
88
+
89
+ export const UnifiWifiBroadcastProvider = () =>
90
+ Provider.effect(UnifiWifiBroadcast, Effect.succeed(UnifiWifiBroadcast.Provider.of(handlers)));