@homeflare/alchemy 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +10 -8
  2. package/dist/ceph/auth-entity.d.ts +34 -0
  3. package/dist/ceph/auth-entity.d.ts.map +1 -0
  4. package/dist/ceph/ceph-argv-config.d.ts +10 -0
  5. package/dist/ceph/ceph-argv-config.d.ts.map +1 -0
  6. package/dist/ceph/ceph-argv.d.ts +19 -0
  7. package/dist/ceph/ceph-argv.d.ts.map +1 -0
  8. package/dist/ceph/ceph-auth-form.d.ts +54 -0
  9. package/dist/ceph/ceph-auth-form.d.ts.map +1 -0
  10. package/dist/ceph/ceph-auth-parse.d.ts +56 -0
  11. package/dist/ceph/ceph-auth-parse.d.ts.map +1 -0
  12. package/dist/ceph/ceph-auth-reconcile.d.ts +35 -0
  13. package/dist/ceph/ceph-auth-reconcile.d.ts.map +1 -0
  14. package/dist/ceph/ceph-config-options.d.ts +23 -0
  15. package/dist/ceph/ceph-config-options.d.ts.map +1 -0
  16. package/dist/ceph/ceph-key-leak-assert.d.ts +25 -0
  17. package/dist/ceph/ceph-key-leak-assert.d.ts.map +1 -0
  18. package/dist/ceph/ceph-transport.d.ts +37 -0
  19. package/dist/ceph/ceph-transport.d.ts.map +1 -0
  20. package/dist/ceph/fake-ceph-dial.d.ts +35 -0
  21. package/dist/ceph/fake-ceph-dial.d.ts.map +1 -0
  22. package/dist/ceph/index.d.ts +13 -0
  23. package/dist/ceph/index.d.ts.map +1 -0
  24. package/dist/ceph/index.js +1609 -0
  25. package/dist/ceph/index.js.map +38 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +15 -6
  29. package/dist/proxmox/index.js.map +3 -3
  30. package/dist/proxmox/qemu-volume.d.ts +12 -2
  31. package/dist/proxmox/qemu-volume.d.ts.map +1 -1
  32. package/dist/talos/credentials-write.d.ts +46 -0
  33. package/dist/talos/credentials-write.d.ts.map +1 -0
  34. package/dist/talos/credentials.d.ts +51 -9
  35. package/dist/talos/credentials.d.ts.map +1 -1
  36. package/dist/talos/fake-process.d.ts +56 -0
  37. package/dist/talos/fake-process.d.ts.map +1 -0
  38. package/dist/talos/index.d.ts +19 -4
  39. package/dist/talos/index.d.ts.map +1 -1
  40. package/dist/talos/index.js +357 -173
  41. package/dist/talos/index.js.map +16 -10
  42. package/dist/talos/kubeconfig-attrs.d.ts +4 -0
  43. package/dist/talos/kubeconfig-attrs.d.ts.map +1 -0
  44. package/dist/talos/kubeconfig.d.ts +41 -23
  45. package/dist/talos/kubeconfig.d.ts.map +1 -1
  46. package/dist/talos/machine-config-fixtures.d.ts +60 -0
  47. package/dist/talos/machine-config-fixtures.d.ts.map +1 -0
  48. package/dist/talos/machine-config-poll.d.ts +43 -0
  49. package/dist/talos/machine-config-poll.d.ts.map +1 -0
  50. package/dist/talos/machine-config-read.d.ts +49 -0
  51. package/dist/talos/machine-config-read.d.ts.map +1 -0
  52. package/dist/talos/resource.d.ts +18 -0
  53. package/dist/talos/resource.d.ts.map +1 -1
  54. package/dist/talos/talos-bootstrap-etcd.d.ts +38 -0
  55. package/dist/talos/talos-bootstrap-etcd.d.ts.map +1 -0
  56. package/dist/talos/talos-bootstrap.d.ts +87 -0
  57. package/dist/talos/talos-bootstrap.d.ts.map +1 -1
  58. package/dist/talos/talos-cluster-health.d.ts +43 -1
  59. package/dist/talos/talos-cluster-health.d.ts.map +1 -1
  60. package/dist/talos/talos-errors.d.ts +68 -0
  61. package/dist/talos/talos-errors.d.ts.map +1 -0
  62. package/dist/talos/talos-machine-config.d.ts +51 -15
  63. package/dist/talos/talos-machine-config.d.ts.map +1 -1
  64. package/dist/talos/talosctl.d.ts +7 -0
  65. package/dist/talos/talosctl.d.ts.map +1 -1
  66. package/dist/talos/values.d.ts +23 -3
  67. package/dist/talos/values.d.ts.map +1 -1
  68. package/dist/unifi/acl-rule-drift.d.ts +22 -0
  69. package/dist/unifi/acl-rule-drift.d.ts.map +1 -0
  70. package/dist/unifi/acl-rule-form.d.ts +90 -0
  71. package/dist/unifi/acl-rule-form.d.ts.map +1 -0
  72. package/dist/unifi/acl-rule-ordering-form.d.ts +49 -0
  73. package/dist/unifi/acl-rule-ordering-form.d.ts.map +1 -0
  74. package/dist/unifi/acl-rule-ordering.d.ts +54 -0
  75. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -0
  76. package/dist/unifi/acl-rule.d.ts +49 -0
  77. package/dist/unifi/acl-rule.d.ts.map +1 -0
  78. package/dist/unifi/dns-policy-drift.d.ts +21 -0
  79. package/dist/unifi/dns-policy-drift.d.ts.map +1 -0
  80. package/dist/unifi/dns-policy-form.d.ts +97 -0
  81. package/dist/unifi/dns-policy-form.d.ts.map +1 -0
  82. package/dist/unifi/dns-policy.d.ts +55 -0
  83. package/dist/unifi/dns-policy.d.ts.map +1 -0
  84. package/dist/unifi/firewall-policy-drift.d.ts +26 -0
  85. package/dist/unifi/firewall-policy-drift.d.ts.map +1 -0
  86. package/dist/unifi/firewall-policy-form.d.ts +102 -0
  87. package/dist/unifi/firewall-policy-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-policy-ordering-form.d.ts +66 -0
  89. package/dist/unifi/firewall-policy-ordering-form.d.ts.map +1 -0
  90. package/dist/unifi/firewall-policy-ordering.d.ts +57 -0
  91. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -0
  92. package/dist/unifi/firewall-policy.d.ts +57 -0
  93. package/dist/unifi/firewall-policy.d.ts.map +1 -0
  94. package/dist/unifi/index.d.ts +7 -0
  95. package/dist/unifi/index.d.ts.map +1 -1
  96. package/dist/unifi/index.js +588 -3
  97. package/dist/unifi/index.js.map +24 -4
  98. package/dist/unifi/paginate.d.ts +0 -12
  99. package/dist/unifi/paginate.d.ts.map +1 -1
  100. package/dist/unifi/providers.d.ts +1 -1
  101. package/dist/unifi/providers.d.ts.map +1 -1
  102. package/dist/unifi/scan-source.d.ts +11 -0
  103. package/dist/unifi/scan-source.d.ts.map +1 -0
  104. package/dist/unifi/traffic-matching-list-drift.d.ts +22 -0
  105. package/dist/unifi/traffic-matching-list-drift.d.ts.map +1 -0
  106. package/dist/unifi/traffic-matching-list-form.d.ts +56 -0
  107. package/dist/unifi/traffic-matching-list-form.d.ts.map +1 -0
  108. package/dist/unifi/traffic-matching-list.d.ts +51 -0
  109. package/dist/unifi/traffic-matching-list.d.ts.map +1 -0
  110. package/dist/unifi/wifi-broadcast-drift.d.ts +22 -0
  111. package/dist/unifi/wifi-broadcast-drift.d.ts.map +1 -0
  112. package/dist/unifi/wifi-broadcast-form.d.ts +127 -0
  113. package/dist/unifi/wifi-broadcast-form.d.ts.map +1 -0
  114. package/dist/unifi/wifi-broadcast.d.ts +73 -0
  115. package/dist/unifi/wifi-broadcast.d.ts.map +1 -0
  116. package/docs/unifi-acl-rule.md +122 -0
  117. package/docs/unifi-dns-policy.md +75 -0
  118. package/docs/unifi-firewall-policy.md +109 -0
  119. package/docs/unifi-traffic-matching-list.md +82 -0
  120. package/docs/unifi-wifi.md +120 -0
  121. package/docs/unifi.md +10 -5
  122. package/package.json +6 -2
  123. package/src/ceph/auth-entity.ts +74 -0
  124. package/src/ceph/ceph-argv-config.ts +78 -0
  125. package/src/ceph/ceph-argv.test.ts +226 -0
  126. package/src/ceph/ceph-argv.ts +189 -0
  127. package/src/ceph/ceph-auth-form.test.ts +103 -0
  128. package/src/ceph/ceph-auth-form.ts +107 -0
  129. package/src/ceph/ceph-auth-parse.test.ts +155 -0
  130. package/src/ceph/ceph-auth-parse.ts +121 -0
  131. package/src/ceph/ceph-auth-reconcile-secrecy.test.ts +82 -0
  132. package/src/ceph/ceph-auth-reconcile.test.ts +233 -0
  133. package/src/ceph/ceph-auth-reconcile.ts +119 -0
  134. package/src/ceph/ceph-config-options.test.ts +44 -0
  135. package/src/ceph/ceph-config-options.ts +36 -0
  136. package/src/ceph/ceph-key-leak-assert.ts +35 -0
  137. package/src/ceph/ceph-no-output-logging.test.ts +52 -0
  138. package/src/ceph/ceph-transport.test.ts +125 -0
  139. package/src/ceph/ceph-transport.ts +117 -0
  140. package/src/ceph/fake-ceph-dial.ts +55 -0
  141. package/src/ceph/index.ts +12 -0
  142. package/src/index.ts +1 -0
  143. package/src/proxmox/qemu-volume.test.ts +153 -4
  144. package/src/proxmox/qemu-volume.ts +71 -14
  145. package/src/talos/credentials-write.test.ts +81 -0
  146. package/src/talos/credentials-write.ts +95 -0
  147. package/src/talos/credentials.test.ts +157 -0
  148. package/src/talos/credentials.ts +160 -90
  149. package/src/talos/fake-process.ts +94 -0
  150. package/src/talos/index.ts +39 -4
  151. package/src/talos/kubeconfig-attrs.ts +55 -0
  152. package/src/talos/kubeconfig.test.ts +256 -0
  153. package/src/talos/kubeconfig.ts +170 -90
  154. package/src/talos/machine-config-fixtures.ts +86 -0
  155. package/src/talos/machine-config-poll.test.ts +57 -0
  156. package/src/talos/machine-config-poll.ts +82 -0
  157. package/src/talos/machine-config-read.ts +118 -0
  158. package/src/talos/resource.ts +19 -0
  159. package/src/talos/secrets-gate.test.ts +63 -0
  160. package/src/talos/talos-bootstrap-etcd.ts +71 -0
  161. package/src/talos/talos-bootstrap.test.ts +218 -0
  162. package/src/talos/talos-bootstrap.ts +151 -45
  163. package/src/talos/talos-cluster-health.test.ts +143 -0
  164. package/src/talos/talos-cluster-health.ts +91 -28
  165. package/src/talos/talos-errors.ts +85 -0
  166. package/src/talos/talos-machine-config.test.ts +189 -0
  167. package/src/talos/talos-machine-config.ts +102 -88
  168. package/src/talos/talosctl.ts +8 -0
  169. package/src/talos/values.test.ts +34 -6
  170. package/src/talos/values.ts +41 -7
  171. package/src/unifi/acl-rule-drift.ts +57 -0
  172. package/src/unifi/acl-rule-form.ts +135 -0
  173. package/src/unifi/acl-rule-ordering-form.ts +79 -0
  174. package/src/unifi/acl-rule-ordering.test.ts +141 -0
  175. package/src/unifi/acl-rule-ordering.ts +70 -0
  176. package/src/unifi/acl-rule.test.ts +183 -0
  177. package/src/unifi/acl-rule.ts +61 -0
  178. package/src/unifi/discriminated-filter-decode.test.ts +134 -0
  179. package/src/unifi/dns-policy-drift.ts +48 -0
  180. package/src/unifi/dns-policy-form.ts +142 -0
  181. package/src/unifi/dns-policy.test.ts +154 -0
  182. package/src/unifi/dns-policy.ts +67 -0
  183. package/src/unifi/firewall-policy-drift.test.ts +151 -0
  184. package/src/unifi/firewall-policy-drift.ts +55 -0
  185. package/src/unifi/firewall-policy-filter-roundtrip.test.ts +143 -0
  186. package/src/unifi/firewall-policy-form.ts +146 -0
  187. package/src/unifi/firewall-policy-ordering-form.ts +111 -0
  188. package/src/unifi/firewall-policy-ordering.test.ts +185 -0
  189. package/src/unifi/firewall-policy-ordering.ts +87 -0
  190. package/src/unifi/firewall-policy.test.ts +183 -0
  191. package/src/unifi/firewall-policy.ts +73 -0
  192. package/src/unifi/index.ts +63 -0
  193. package/src/unifi/paginate.test.ts +24 -0
  194. package/src/unifi/paginate.ts +15 -3
  195. package/src/unifi/providers.ts +19 -1
  196. package/src/unifi/scan-source.ts +64 -0
  197. package/src/unifi/traffic-matching-list-drift.ts +40 -0
  198. package/src/unifi/traffic-matching-list-form.ts +77 -0
  199. package/src/unifi/traffic-matching-list.test.ts +202 -0
  200. package/src/unifi/traffic-matching-list.ts +78 -0
  201. package/src/unifi/wifi-broadcast-details-call.test.ts +92 -0
  202. package/src/unifi/wifi-broadcast-drift.ts +69 -0
  203. package/src/unifi/wifi-broadcast-form.ts +211 -0
  204. package/src/unifi/wifi-broadcast-secrets.test.ts +145 -0
  205. package/src/unifi/wifi-broadcast.test.ts +155 -0
  206. package/src/unifi/wifi-broadcast.ts +90 -0
  207. package/src/unifi/write-op-reference.test.ts +8 -54
@@ -0,0 +1,82 @@
1
+ # UniFi Traffic Matching Lists — `Unifi.TrafficMatchingList`
2
+
3
+ ⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** Same posture as `docs/unifi.md`'s `Unifi.Network`/
4
+ `Unifi.FirewallZone`: `reconcile`/`delete` both fail with a typed `UnifiWriteRefused`, and the
5
+ `GetOnlyHttpClient` wire guard (`resource.ts`) refuses any non-`GET` request before it reaches the
6
+ transport. This family never calls `createTrafficMatchingList`, `updateTrafficMatchingList` or
7
+ `deleteTrafficMatchingList` anywhere — `write-op-reference.test.ts` proves it statically.
8
+
9
+ ## The simplest object shape in this directory
10
+
11
+ `TrafficMatchingList` is `{id, name, type, items?}` — no `metadata` field at all, unlike every
12
+ other family here (`FirewallZone`/`DNSPolicy`/`ACLRule`/`FirewallPolicy` all carry a server-derived
13
+ `metadata.origin`). Nothing in `TrafficMatchingListAttributes` is attributes-only the way
14
+ `metadataOrigin`/`index` are elsewhere: every field this family reads is also declarable.
15
+
16
+ ## `items` is a real TS union post-A3, compared wholesale
17
+
18
+ `items` decodes as `Array<IPv4Matching> | Array<IPv6Matching> | Array<PortMatching>`, keyed by the
19
+ sibling `type` field (A3, `8ee11f1`) — previously opaque `unknown`. The runtime schema is still a
20
+ passthrough `S.Unknown` (Argo CD's `union:` callback): narrower for a reader at the TYPE level only,
21
+ so decode keeps every key including one this pinned spec doesn't yet declare, the same proof
22
+ `discriminated-filter-decode.test.ts` runs for `FirewallPolicy`/`AclRule`.
23
+
24
+ ⚠️ **Known gap (T15, same shape `acl-rule-form.ts`/`docs/unifi-firewall-policy.md` both carry)**: a
25
+ traffic matching list's `items` is semantically a SET of match entries — matching is "is this
26
+ traffic covered by ANY entry," not order-dependent — but each entry is an OBJECT
27
+ (`{type, value?, start?, stop?}`), not a bare string, so there is no cheap canonical sort key the
28
+ way `sortedSet` gives a `string[]`. `deepEqual` compares `items` order-sensitively, so a
29
+ console-side reorder with no membership change would report a spurious drift this read-only
30
+ family's `reconcile` then refuses forever. Accepted, not fixed: a generic object-set normalizer is
31
+ bigger, separate modelling work, not a mechanical extension of typing `items` per-discriminator.
32
+
33
+ ## T11 gate: per-tag closure diff, 10.4.57 vs 10.6.97 (checked 2026-09-26)
34
+
35
+ Same method `docs/unifi-acl-rule.md`/`docs/unifi-firewall-policy.md` use, run here for the
36
+ `Traffic Matching Lists` tag's 5 operations (`getTrafficMatchingLists`, `createTrafficMatchingList`,
37
+ `deleteTrafficMatchingList`, `getTrafficMatchingList`, `updateTrafficMatchingList`).
38
+
39
+ - **Pin**: `~/.cache/homeflare/schemas/unifi/network_v10.4.57_openapi.json`, re-verified against
40
+ its own `SHA256SUMS` today (`3773947b…c9df59d`).
41
+ - **Mirror**: `raw.githubusercontent.com/beezly/unifi-apis/main/unifi-network/10.6.97.json`,
42
+ diffing aid only, never a spec of record — re-fetched today, still `info.version: "10.6.97"`,
43
+ `sha256: 44fd0e7f81603bb1279889a528a443d8f1a0102436e4a29675d8451adf32bd78`, unchanged since mirror
44
+ commit `f979dd64ec0ea6008c2a698c278fe49a10e102c8` (Andrew Beresford, 2026-08-20, "Add UniFi API
45
+ specs: Network 10.6.97", GitHub-verified signature) — the same file every other family doc cites.
46
+ - **Operation count**: both versions carry exactly the same 5 operations for `Traffic Matching
47
+ Lists` — zero added, zero removed, either direction.
48
+ - **Full raw operation objects** (parameters, request body, responses, unresolved `$ref`s included):
49
+ byte-identical for all 5, both versions.
50
+ - **Reachability** (`$ref` AND `discriminator.mapping`): the closure is **19 schema names**
51
+ (`IPv4Matching`/`IPv6Matching`/`Port matching` and each of the three list-shape Dto pairs, plus
52
+ their subnet/range/address variant Dtos), identical set in both versions. **Zero overlap** with
53
+ the 14 schemas that changed elsewhere in the document — re-diffed all 19 with an
54
+ order-independent structural compare: **zero differ**.
55
+ - **Corroboration**: the A3 changeset's own broader re-check
56
+ (`.changeset/unifi-network-a3-typed-discriminator-filters.md`) independently walked all 25
57
+ FirewallPolicy/ACL rule/TrafficMatchingList operations together — a 139-schema closure, also
58
+ byte-identical between versions — against a mirror fetch the same day; see
59
+ `docs/unifi-firewall-policy.md`'s own T11 section for why that citation's different-looking
60
+ mirror commit is the same unchanged file.
61
+
62
+ **Answer: `TrafficMatchingList` would not decode differently against 10.6.97.** This does not
63
+ generalize past 10.6.97 or to any other tag — re-run this check, following BOTH `$ref` and
64
+ `discriminator.mapping`, before importing or declaring against `Clients`, `Switching`, or any tag
65
+ not yet covered by an existing `docs/unifi-*.md`.
66
+
67
+ ## The declaration renderer
68
+
69
+ `declareTrafficMatchingList(live, siteId)` is a pure function, same contract as every other
70
+ family's own (`docs/unifi.md`): given one `getTrafficMatchingList` response, it returns the `Props`
71
+ a declaration needs so its plan is a no-op.
72
+
73
+ ## Field-level drift
74
+
75
+ `traffic-matching-list.ts` exports a pure `driftOf(live, props)` (`traffic-matching-list-drift.ts`),
76
+ built on the same `drift.ts`'s `makeDriftOf` framework as every other family — `matches` is exactly
77
+ `fieldDrift(...).length === 0`, never a second hand-written comparison (MEDIUM-4).
78
+
79
+ ## Vendor version
80
+
81
+ UniFi Network Integration API **10.4.57**, same pin as `docs/unifi.md`, via
82
+ `@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.3.0`.
@@ -0,0 +1,120 @@
1
+ # UniFi WiFi Broadcasts — `Unifi.WifiBroadcast`
2
+
3
+ ⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** Same posture as `docs/unifi.md`'s `Unifi.Network`/
4
+ `Unifi.FirewallZone`: `reconcile`/`delete` both fail with a typed `UnifiWriteRefused`, and the
5
+ `GetOnlyHttpClient` wire guard (`resource.ts`) refuses any non-`GET` request before it reaches the
6
+ transport. This family calls no SDK write op anywhere — `write-op-reference.test.ts` proves it
7
+ statically, and `wifi-broadcast.test.ts`'s own write-path tests prove `reconcile`/`destroy` never
8
+ send anything but `GET`s.
9
+
10
+ ## T23: the passphrase-bearing details call is never made
11
+
12
+ The pinned spec's WiFi Broadcast "details" shape (`getWifiBroadcastDetails`, and the create/update
13
+ request bodies) — `WifiSecurityConfigurationDetailObject` — carries the WPA passphrase AND every
14
+ PPSK entry's own passphrase, with **no `writeOnly` flag on either** (verified: 4 `passphrase`
15
+ properties, 0 `writeOnly` flags, in the staged 10.4.57 spec). `README.md`'s claim that these fields
16
+ are "write-only" was wrong; A2 (kit PR #300) fixed the SDK layer by decoding them `Redacted`
17
+ instead of a plain string, proven at the wire in `errors-and-secrets.test.ts`.
18
+
19
+ This family goes further and **never calls that shape at all.** The only SDK call
20
+ `wifi-broadcast.ts` makes is `getWifiBroadcastPage` — the LIST endpoint, which answers
21
+ `WifiBroadcastOverview` rows. That type's own `securityConfiguration`
22
+ (`WifiSecurityConfigurationOverview`) is `{type, presharedKeyNetworkIds}`: no passphrase field
23
+ exists on it, so there is no DECLARED field for `attributesOf`/`declareWifiBroadcast` to name a
24
+ passphrase into. A static guard (`wifi-broadcast-details-call.test.ts`) additionally bans any
25
+ source reference to `getWifiBroadcastDetails` itself under `src/unifi` (red team, IMPORTANT-2),
26
+ with one named exception: `errors-and-secrets.test.ts` (A2) calls it directly on purpose, to prove
27
+ the SDK's own redaction happens at the wire — a different, lower-layer claim than this family ever
28
+ needing to make that call.
29
+
30
+ ⛔ **This is NOT enforced by the type system alone** (red team, IMPORTANT-1) — corrected from an
31
+ earlier version of this doc that overclaimed it was. `@distilled.cloud/core`'s wire decode does not
32
+ strip a key a schema doesn't declare, so a stray key nested inside `network`, `hotspotConfiguration`,
33
+ `broadcastingDeviceFilter`, or one `presharedKeyNetworkIds` element could ride through a naive
34
+ pass-through exactly like the top-level `securityConfiguration` object could. See "The stray-key
35
+ defense" below — enumeration at runtime, not the TS annotation, is what actually stops it.
36
+
37
+ ### No get-by-id call exists for the overview shape
38
+
39
+ Unlike every other family here (`getNetworkDetails`, `getFirewallZone`, `getDnsPolicy`,
40
+ `getAclRule`), `wifi_broadcasts.ts` has no single-object GET that returns the overview shape — only
41
+ the list (safe) and the details call (forbidden above). `wifi-broadcast.ts`'s `fetchLive` walks
42
+ every page with B0b's `pageAll` (`paginate.ts`) and finds the row matching `wifiBroadcastId` — the
43
+ first real resource-level consumer of that pager, previously exercised only in isolation by
44
+ `paginate.test.ts`. A genuine site-not-found 404 on ANY page of the walk — not only the first —
45
+ folds to `undefined` like every other family, discarding whatever rows the walk had already
46
+ collected (red team, MINOR-4: an earlier version of this doc said "on the first page call", which
47
+ overstated how narrowly the catch is scoped); the target id simply not being among the collected
48
+ rows folds to `undefined` too — there is no separate vendor signal to tell any of these cases apart,
49
+ and no other family's `fetchLive` has one either.
50
+
51
+ ### The stray-key defense (why `normalizeSecurityConfiguration` never spreads)
52
+
53
+ Nothing in `@distilled.cloud/core`'s wire key-mapping strips an object key the schema does not
54
+ declare — it only renames declared ones (measured directly against the installed
55
+ `@distilled.cloud/core@1.0.0-rc.12`: a struct decode keeps every key from the raw response,
56
+ declared or not). So a stray `passphrase`-shaped key on the WIRE `securityConfiguration` object — a
57
+ vendor bug, or a future console version — physically survives into the `live` object `fetchLive`
58
+ returns, even though `WifiBroadcastOverview`'s own TS type has no such field.
59
+
60
+ `wifi-broadcast-form.ts`'s `normalizeSecurityConfiguration` is the one normalizer in this whole
61
+ `Unifi.*` tree that does **not** use `{...value}` (every sibling normalizer, `network-form.ts`'s
62
+ included, spreads its input). It enumerates exactly `{type, presharedKeyNetworkIds}` instead —
63
+ `attributesOf`/`declareWifiBroadcast` are built the same way, field by field, never `{...live}`.
64
+
65
+ ⛔ **The same discipline applies to every OTHER nested object this family handles, not just
66
+ `securityConfiguration`** (red team, IMPORTANT-1) — `normalizeRef` rebuilds `network` and each
67
+ `presharedKeyNetworkIds` element as `{type, networkId?}`, `normalizeHotspot` rebuilds
68
+ `hotspotConfiguration` as `{type}`, and `normalizeDeviceFilter` no longer spreads
69
+ `broadcastingDeviceFilter` either. A `presharedKeyNetworkIds` element is the single most plausible
70
+ leak site of the four: `IntegrationWifiPresharedKeyDto`, the details-call shape for a PPSK entry, is
71
+ literally `{network: WifiNetworkReference, passphrase}` — the same reference shape this family's
72
+ overview also uses, just with one more field the overview type doesn't declare.
73
+
74
+ `wifi-broadcast-secrets.test.ts`'s sentinel tests prove the pipeline for all five locations
75
+ (`securityConfiguration`, a `presharedKeyNetworkIds` element, `network`, `hotspotConfiguration`,
76
+ `broadcastingDeviceFilter`): each plants a raw sentinel key that genuinely does survive into `live`
77
+ (asserted directly, not assumed away), then proves `attributesOf`, `declareWifiBroadcast`, and the
78
+ literal string a later import script would render into `alchemy.run.ts` none of them carry it
79
+ forward.
80
+
81
+ ### The forced decode-failure test
82
+
83
+ `wifi-broadcast-secrets.test.ts`'s decode-failure test sends a non-JSON 200 body (a genuine decode
84
+ failure — this SDK's REST decode has no schema-validating step to reject a malformed body with; see
85
+ `@distilled.cloud/core`'s `protocol-rest.ts`) that happens to contain a sentinel value, the way a
86
+ garbled proxy or debug response might. `pageAll`'s own consistency check (`paginate.ts`) catches it
87
+ — the malformed "page" has no `.offset`, so the walk refuses to trust it — and fails typed as
88
+ `UnifiPaginationInconsistent`. That error's `reason`/`detail`/`message` are built from `offset`/
89
+ `totalCount` values run through `paginate.ts`'s `numOrPlaceholder` (red team, MINOR-3: an earlier
90
+ version of this doc said these fields were "never from the raw response body", but `offset`/
91
+ `totalCount` themselves ARE unvalidated wire values — `paginate.test.ts`'s own test proves a
92
+ non-numeric one renders as a fixed placeholder, never as itself), so none of them can ever echo
93
+ attacker- or vendor-controlled text regardless of what the server actually sent.
94
+
95
+ ## Field-level drift
96
+
97
+ Three fields are membership SETS, not ordered sequences, by the vendor description's own wording
98
+ ("Defines the custom scope of devices…") and unconfirmed against a live console (same caveat
99
+ `network-form.ts` carries for its own three): `broadcastingDeviceFilter.deviceIds`/`.deviceTagIds`
100
+ and `securityConfiguration.presharedKeyNetworkIds`. `wifi-broadcast-form.ts`'s
101
+ `normalizeDeviceFilter`/`normalizeSecurityConfiguration`/`normalizeFrequencies` sort+dedupe them
102
+ before comparison, on both the live AND declared sides (`wifi-broadcast-drift.ts`), the same
103
+ `firewall-zone-form.ts`'s `networkIds` pattern — otherwise a console returning the same set in a
104
+ different order would plan a spurious `update` that a read-only `reconcile` then refuses.
105
+
106
+ `wifi-broadcast.ts` exports a pure `driftOf(live, props)`, built on `drift.ts`'s `makeDriftOf`
107
+ framework. `matches` is exactly `fieldDrift(...).length === 0` (MEDIUM-4).
108
+
109
+ ## The declaration renderer
110
+
111
+ `declareWifiBroadcast(live, siteId)` is a pure function: given one `WifiBroadcastOverview` row (as
112
+ `getWifiBroadcastPage` returns it, never `getWifiBroadcastDetails`), it returns the `Props` a
113
+ declaration needs so its plan is a no-op. A later import script pages every WiFi broadcast on a
114
+ site and writes one `wifiBroadcast('<id>', declareWifiBroadcast(live, siteId))` row per result —
115
+ nothing here performs that write.
116
+
117
+ ## Vendor version
118
+
119
+ UniFi Network Integration API **10.4.57**, same pin as `docs/unifi.md`, via
120
+ `@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.3.0`.
package/docs/unifi.md CHANGED
@@ -6,6 +6,11 @@ with a typed `UnifiWriteRefused` naming the policy rather than calling any SDK w
6
6
  Lifting the rule is a kit change — a PR that adds a write path, reviewed as one — not a flag a
7
7
  stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
8
8
 
9
+ This page covers `Unifi.Network`/`Unifi.FirewallZone` only. Every other read-only family has its
10
+ own doc, per family (`docs/unifi.md` would breach the 200-line cap otherwise): [`Unifi.DnsPolicy`](./unifi-dns-policy.md), [`Unifi.AclRule`/`Unifi.AclRuleOrdering`](./unifi-acl-rule.md),
11
+ [`Unifi.WifiBroadcast`](./unifi-wifi.md), [`Unifi.FirewallPolicy`/`Unifi.FirewallPolicyOrdering`](./unifi-firewall-policy.md),
12
+ and [`Unifi.TrafficMatchingList`](./unifi-traffic-matching-list.md).
13
+
9
14
  ## What's declared, and why these two first
10
15
 
11
16
  `Unifi.Network` (`getNetworkDetails`/`getNetworksOverviewPage`) and `Unifi.FirewallZone`
@@ -13,9 +18,8 @@ stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
13
18
  not runtime state (a connected client, a device statistic, a hotspot voucher, a switch stack).
14
19
  `Site` was the SDK README's other first suggestion; it has no `getSite`, only the list operation,
15
20
  so it fails this house's own "both a list and a get" bar for a first import. `FirewallPolicy` is
16
- also a list+get config object, but its `source`/`destination`/`ipProtocolScope` fields carry
17
- several of the vendor's converter-flattened discriminator variants each, and its ordering
18
- endpoint replaces the whole rule list — modelling that correctly is a bigger, separate PR.
21
+ also a list+get config object; modelling it correctly needed its own PR (now shipped — [`docs/unifi-firewall-policy.md`](./unifi-firewall-policy.md)) because its ordering endpoint replaces the whole
22
+ rule list per zone pair, and it was gated on A3 typing its filter fields' discriminator variants first.
19
23
 
20
24
  ## Credentials
21
25
 
@@ -102,8 +106,9 @@ operations (`getNetworksOverviewPage`, `getFirewallZones`, …), none of which p
102
106
  own — no `smithy.api#paginated` trait exists in the pinned spec, and neither the SDK nor
103
107
  `@distilled.cloud/core` gained one for this. It stops on the response's `totalCount`, never on
104
108
  `data.length < limit` (a page can legitimately come back short of the requested limit without
105
- being the last page). No family here has enough rows to need it yet; it exists ahead of
106
- `Unifi.FirewallPolicy` (hundreds of rows on a real console), which does.
109
+ being the last page). `Unifi.WifiBroadcast` (`docs/unifi-wifi.md`) is its first resource-level
110
+ consumer, walking every page because that family has no get-by-id call at all; `docs/unifi-firewall-policy.md`'s own decode-proof test exercises the same page shape for `getFirewallPolicies`
111
+ (hundreds of rows on a real console — T9), though `Unifi.FirewallPolicy` itself still reads by id.
107
112
 
108
113
  It also fails with a typed `UnifiPaginationInconsistent` — never silently returns a wrong row
109
114
  set — the moment a paged response contradicts itself: a page's echoed `offset` doesn't match what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -56,6 +56,10 @@
56
56
  "types": "./dist/proxmox/index.d.ts",
57
57
  "default": "./dist/proxmox/index.js"
58
58
  },
59
+ "./ceph": {
60
+ "types": "./dist/ceph/index.d.ts",
61
+ "default": "./dist/ceph/index.js"
62
+ },
59
63
  "./launchd": {
60
64
  "types": "./dist/launchd/index.d.ts",
61
65
  "default": "./dist/launchd/index.js"
@@ -131,7 +135,7 @@
131
135
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
132
136
  "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.3.3",
133
137
  "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.3.1",
134
- "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.3.0"
138
+ "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.4.0"
135
139
  },
136
140
  "peerDependencies": {
137
141
  "@distilled.cloud/argocd": "1.0.0-rc.12",
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `Ceph.AuthEntity` (K-A4) — a Ceph client auth entity (`client.k8s-*`), minted over the ssh
3
+ * mon-command transport and stored only in OpenBao. Design: docs/plans/2026-09-26-ceph-mon-
4
+ * transport.md, "`Ceph.AuthEntity` (K-A4)"; the loop itself lives in ceph-auth-reconcile.ts, the
5
+ * transport in ceph-transport.ts, the allowlist in ceph-argv.ts.
6
+ *
7
+ * ⛔ `read` AND `diff` NEVER SSH. "A plan never elevates" (../linux/sudo-runner.ts's own header
8
+ * makes the same rule for the file family): this family's props/state comparison is the whole
9
+ * of `diff`, and `read` hands back exactly the attributes state already holds. Live drift is
10
+ * caught only at `reconcile` (which does elevate, on the admin lane) and by the post-deploy
11
+ * operator check — this family cannot prove live convergence at plan time, and says so rather
12
+ * than pretending an unprivileged probe exists where none does.
13
+ * ⛔ `delete` IS REFUSED UNCONDITIONALLY. `defaultRemovalPolicy: 'retain'` keeps Alchemy from ever
14
+ * calling it on an ordinary `destroy`, and the handler itself refuses too — belt and suspenders
15
+ * with ceph-argv.ts's own refusal of the `auth del` shape, so there is no path, `--adopt` or a
16
+ * resource-level removal-policy override included, that ever reaches it (D3).
17
+ * ⛔ NEVER ADOPTED. See ceph-auth-reconcile.ts's header for why the generic `refuseTakeover` is not
18
+ * used here.
19
+ * ⛔ ROWS ARE CREATES THROUGH THE FIRST-CREATE GATE, NO `adopt()` — `list` answers empty, the same
20
+ * as every Ceph family in this kit (ceph-pool.ts's header has the reason a non-empty list would
21
+ * invite exactly the takeover this family refuses).
22
+ */
23
+ import { Resource } from 'alchemy';
24
+ import { isResolved } from 'alchemy/Diff';
25
+ import * as Provider from 'alchemy/Provider';
26
+ import * as Effect from 'effect/Effect';
27
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
28
+ import type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
29
+ import { planCephAuthEntity } from './ceph-auth-form.ts';
30
+ import { reconcileCephAuthEntity } from './ceph-auth-reconcile.ts';
31
+
32
+ export type { CephAuthEntityAttributes, CephAuthEntityProps } from './ceph-auth-form.ts';
33
+ export type { CephCaps } from './ceph-auth-parse.ts';
34
+
35
+ export interface CephAuthEntity extends Resource<
36
+ 'Ceph.AuthEntity',
37
+ CephAuthEntityProps,
38
+ CephAuthEntityAttributes,
39
+ never,
40
+ HttpClient.HttpClient
41
+ > {}
42
+
43
+ /** ★ `retain` — the delete handler below refuses regardless, but this stops Alchemy calling it. */
44
+ export const CephAuthEntity = Resource<CephAuthEntity>('Ceph.AuthEntity', {
45
+ defaultRemovalPolicy: 'retain',
46
+ });
47
+
48
+ const refusedDelete = (entity: string): Error =>
49
+ new Error(
50
+ `Ceph.AuthEntity ${entity}: 'ceph auth del' is refused entirely — lockout safety, never ` +
51
+ 'auto-delete (D3). Nothing was sent to any mon. If the entity truly must go, remove it by ' +
52
+ 'hand, deliberately, along with its OpenBao key — this family will never do it for you.',
53
+ );
54
+
55
+ export const CephAuthEntityProvider = () =>
56
+ Provider.effect(
57
+ CephAuthEntity,
58
+ Effect.succeed(
59
+ CephAuthEntity.Provider.of({
60
+ list: () => Effect.succeed([]),
61
+
62
+ /** State only — see the header. Never `Unowned`: this family is never adopted. */
63
+ read: ({ output }) => Effect.succeed(output),
64
+
65
+ /** State only — see the header. `output === undefined` lets reconcile decide (a create). */
66
+ diff: ({ news, output }) =>
67
+ Effect.succeed(isResolved(news) ? planCephAuthEntity(news, output) : undefined),
68
+
69
+ reconcile: ({ news, output }) => reconcileCephAuthEntity(news, output, {}),
70
+
71
+ delete: ({ output }) => Effect.die(refusedDelete(output.entity)),
72
+ }),
73
+ ),
74
+ );
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `ceph config get/set/rm` half of the argv allowlist — split out of ceph-argv.ts (which
3
+ * re-exports `configProblem`) purely to keep that file under the house's 250-line file cap; the
4
+ * split follows the same line the design docs already draw (2026-09-26-ceph-config-options.md is
5
+ * its own companion to 2026-09-26-ceph-mon-transport.md).
6
+ *
7
+ * ⚠️ `<who>` IS NOT YET BOUNDED TO K-D's "declared daemons" (the config-options design's own
8
+ * acceptance test #1). `client.admin` still matches this shape. Latent today:
9
+ * `NAMED_CONFIG_OPTIONS` starts empty, so `namedOptionProblem` refuses every `config get/set/rm`
10
+ * regardless of `who` — LAND red team, 2026-09-26, flagged this as armed only once K-D's first
11
+ * named option lands, and narrowing it needs that family's own declared-daemon list, which does
12
+ * not exist yet. Tracked for `CephConfigOption` (K-D), not fixed here.
13
+ */
14
+ import { NAMED_CONFIG_OPTIONS, isLockoutConfigOption } from './ceph-config-options.ts';
15
+
16
+ /** `config`'s `<who>`: a daemon class, optionally `.id` — never a path, never free text. */
17
+ const WHO = /^(global|mon|osd|mgr|mds|client)(\.[A-Za-z0-9_-]{1,32})?$/;
18
+ /** A ceph option name's own shape — snake_case, lowercase. Independent of the named list below. */
19
+ const OPTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
20
+ /**
21
+ * No shell metacharacters; the named list (below) is what actually decides secrecy, not this.
22
+ * ⛔ NO LEADING `-` EXCEPT A NEGATIVE INTEGER, AND NEVER EMPTY. LAND red team (2026-09-26),
23
+ * CONFIRMED: the prior pattern let a config VALUE start with `-`, and this argv reaches
24
+ * `sudo -n /usr/bin/ceph` with no shell in between — no shell means no quoting boundary, so
25
+ * `ceph config set global x -o/etc/ceph/ceph.conf` parses as ceph's own `-o <file>` flag, not
26
+ * as the string `-o/etc/ceph/ceph.conf`. Accepted flag-shaped values included
27
+ * `--admin-daemon=/var/run/ceph/ceph-mon.a.asok`, `--conf=/tmp/x`, `-n`, and the empty string.
28
+ * The sudoers entry has no argument restriction of its own (that is D4, a later step), so this
29
+ * allowlist is the only thing standing between a config value and an arbitrary ceph CLI flag.
30
+ * Latent while `NAMED_CONFIG_OPTIONS` is empty; K-D's first entry arms it.
31
+ */
32
+ const CONFIG_VALUE = /^(?:[A-Za-z0-9][A-Za-z0-9 ._:/=,-]{0,255}|-[0-9]{1,255})$/;
33
+
34
+ /**
35
+ * `undefined` when `value` is a safe ceph config value — exported (like `boundedEntityProblem`)
36
+ * so the shape itself is directly testable, since `NAMED_CONFIG_OPTIONS` being empty today means
37
+ * `cephCommandProblem` never reaches this check on any real argv (`namedOptionProblem` refuses
38
+ * first) — this stays exercised until K-D's first named option arms the path for real.
39
+ */
40
+ export const configValueProblem = (value: string): string | undefined =>
41
+ CONFIG_VALUE.test(value)
42
+ ? undefined
43
+ : `config value ${JSON.stringify(value)} contains characters outside the safe set`;
44
+
45
+ /**
46
+ * `undefined` when `name` may be declared, adopted, read by value or written — see
47
+ * ceph-config-options.ts's header for why the list starts empty and stays reviewed line by line.
48
+ */
49
+ const namedOptionProblem = (name: string): string | undefined => {
50
+ if (!OPTION_NAME.test(name)) return `${JSON.stringify(name)} is not a plain ceph option name`;
51
+ if (isLockoutConfigOption(name))
52
+ return `${name} is a lockout-class option and can never be declared`;
53
+ return NAMED_CONFIG_OPTIONS.has(name)
54
+ ? undefined
55
+ : `${name} is not on the named option list (it starts empty — see ceph-config-options.ts)`;
56
+ };
57
+
58
+ /** `config get|set|rm <who> <name> [value]`, called with the operands after `config` stripped. */
59
+ export const configProblem = (rest: readonly string[]): string | undefined => {
60
+ const [verb, who, name, ...extra] = rest;
61
+ if (who === undefined || !WHO.test(who)) {
62
+ return 'config takes a bounded `<who>` (global/mon/osd/mgr/mds/client[.id])';
63
+ }
64
+ if (name === undefined) return 'config takes a `<name>` from the named option list';
65
+ if (verb === 'get' || verb === 'rm') {
66
+ return extra.length === 0
67
+ ? namedOptionProblem(name)
68
+ : `config ${String(verb)} takes exactly \`<who> <name>\``;
69
+ }
70
+ if (verb === 'set') {
71
+ const [value, ...more] = extra;
72
+ if (value === undefined || more.length !== 0)
73
+ return 'config set takes exactly `<who> <name> <value>`';
74
+ const named = namedOptionProblem(name);
75
+ return named !== undefined ? named : configValueProblem(value);
76
+ }
77
+ return `ceph config ${String(verb)} is not on the allowlist`;
78
+ };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The ceph allowlist, as pure argv checks — no ssh, no fake dial. Every acceptance is a shape
3
+ * ceph-argv.ts's own builders actually produce; every refusal is a shape that would do real damage
4
+ * (a printed key, a cut mon, a de-authenticated cluster) if it ever reached `sudo -n ceph`.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import {
8
+ CEPH_BIN,
9
+ authCapsArgv,
10
+ authGetArgv,
11
+ authGetOrCreateArgv,
12
+ boundedEntityProblem,
13
+ cephCommandProblem,
14
+ configValueProblem,
15
+ quorumStatusArgv,
16
+ } from './ceph-argv.ts';
17
+
18
+ const ENTITY = 'client.k8s-rbd';
19
+ const CAPS = {
20
+ mgr: 'profile rbd pool=k8s-rbd',
21
+ mon: 'profile rbd',
22
+ osd: 'profile rbd pool=k8s-rbd',
23
+ };
24
+
25
+ describe('the allowed shapes, byte-for-byte', () => {
26
+ test('auth get', () => {
27
+ expect(cephCommandProblem(authGetArgv(ENTITY))).toBeUndefined();
28
+ expect(authGetArgv(ENTITY)).toEqual([CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'json']);
29
+ });
30
+
31
+ test('auth get-or-create', () => {
32
+ expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
33
+ expect(authGetOrCreateArgv(ENTITY, CAPS)).toEqual([
34
+ CEPH_BIN,
35
+ 'auth',
36
+ 'get-or-create',
37
+ ENTITY,
38
+ 'mon',
39
+ CAPS.mon,
40
+ 'osd',
41
+ CAPS.osd,
42
+ 'mgr',
43
+ CAPS.mgr,
44
+ '-f',
45
+ 'json',
46
+ ]);
47
+ });
48
+
49
+ test('auth caps', () => {
50
+ expect(cephCommandProblem(authCapsArgv(ENTITY, CAPS))).toBeUndefined();
51
+ });
52
+
53
+ test('quorum_status', () => {
54
+ expect(cephCommandProblem(quorumStatusArgv())).toBeUndefined();
55
+ expect(quorumStatusArgv()).toEqual([CEPH_BIN, 'quorum_status', '-f', 'json']);
56
+ });
57
+ });
58
+
59
+ describe('the entity prefix', () => {
60
+ test('accepts a bounded client.k8s- name', () => {
61
+ expect(boundedEntityProblem('client.k8s-rbd')).toBeUndefined();
62
+ expect(boundedEntityProblem('client.k8s-cephfs')).toBeUndefined();
63
+ });
64
+
65
+ test.each([
66
+ ['the cluster admin keyring', 'client.admin'],
67
+ ['a mon keyring', 'mon.a'],
68
+ ['an osd keyring', 'osd.0'],
69
+ ['an mgr keyring', 'mgr.a'],
70
+ ['the PVE storage client', 'client.pve-storage'],
71
+ ['a bare client prefix', 'client.k8s-'],
72
+ ['no client prefix at all', 'k8s-rbd'],
73
+ ])('refuses %s', (_name, entity) => {
74
+ expect(boundedEntityProblem(entity)).toBeString();
75
+ expect(cephCommandProblem(authGetArgv(entity))).toBeString();
76
+ });
77
+ });
78
+
79
+ describe('shell metacharacters never reach the allowlist as data', () => {
80
+ test.each([
81
+ ['in the entity', authGetArgv('client.k8s-rbd; rm -rf /')],
82
+ ['in a cap, backtick', authGetOrCreateArgv(ENTITY, { ...CAPS, osd: 'profile rbd `id`' })],
83
+ ['in a cap, command substitution', authCapsArgv(ENTITY, { ...CAPS, mon: '$(whoami)' })],
84
+ ['in a cap, a pipe', authCapsArgv(ENTITY, { ...CAPS, mgr: 'profile rbd | cat /etc/passwd' })],
85
+ [
86
+ 'in a cap, a semicolon',
87
+ authCapsArgv(ENTITY, { ...CAPS, mon: 'profile rbd; ceph auth del client.admin' }),
88
+ ],
89
+ ])('refuses %s', (_name, argv) => {
90
+ expect(cephCommandProblem(argv)).toBeString();
91
+ });
92
+ });
93
+
94
+ describe('K-A4 finding 3: caps are the design’s literal shape, not a general grammar', () => {
95
+ test.each([
96
+ ['plain rwx, no profile at all', 'osd', 'allow rwx'],
97
+ ['a non-rbd profile', 'osd', 'profile osd'],
98
+ ['bootstrap-osd', 'osd', 'profile bootstrap-osd'],
99
+ ['a comma-chained extra grant', 'osd', 'profile rbd pool=k8s-rbd, allow rwx'],
100
+ ['osd rbd with no pool — every pool, VM disks included', 'osd', 'profile rbd'],
101
+ ['mgr rbd with no pool', 'mgr', 'profile rbd'],
102
+ ['mon widened past the plain profile', 'mon', 'profile rbd pool=k8s-rbd'],
103
+ ])('refuses %s (%s cap %j)', (_name, daemon, value) => {
104
+ const caps = { ...CAPS, [daemon]: value };
105
+ expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, caps))).toBeString();
106
+ expect(cephCommandProblem(authCapsArgv(ENTITY, caps))).toBeString();
107
+ });
108
+
109
+ test('the design’s own three caps are still accepted', () => {
110
+ expect(cephCommandProblem(authGetOrCreateArgv(ENTITY, CAPS))).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe('the never-allowed auth verbs', () => {
115
+ test('auth ls is refused, naming the reason', () => {
116
+ const problem = cephCommandProblem([CEPH_BIN, 'auth', 'ls', '-f', 'json']);
117
+ expect(problem).toContain('prints every key');
118
+ });
119
+
120
+ test('auth del is refused, naming lockout safety, for any entity', () => {
121
+ for (const entity of [ENTITY, 'client.admin', 'mon.a']) {
122
+ const problem = cephCommandProblem([CEPH_BIN, 'auth', 'del', entity, '-f', 'json']);
123
+ expect(problem).toContain('never auto-delete');
124
+ }
125
+ });
126
+ });
127
+
128
+ describe('shape mismatches', () => {
129
+ test.each([
130
+ ['bare ceph, no group', [CEPH_BIN]],
131
+ ['an unknown group', [CEPH_BIN, 'status', '-f', 'json']],
132
+ ['auth get with an extra operand', [CEPH_BIN, 'auth', 'get', ENTITY, 'extra', '-f', 'json']],
133
+ ['auth get with an unexpected flag', [CEPH_BIN, 'auth', 'get', ENTITY, '--yes', '-f', 'json']],
134
+ [
135
+ 'auth get-or-create missing a daemon',
136
+ [CEPH_BIN, 'auth', 'get-or-create', ENTITY, 'mon', CAPS.mon, '-f', 'json'],
137
+ ],
138
+ [
139
+ 'auth caps with daemons out of order',
140
+ [
141
+ CEPH_BIN,
142
+ 'auth',
143
+ 'caps',
144
+ ENTITY,
145
+ 'osd',
146
+ CAPS.osd,
147
+ 'mon',
148
+ CAPS.mon,
149
+ 'mgr',
150
+ CAPS.mgr,
151
+ '-f',
152
+ 'json',
153
+ ],
154
+ ],
155
+ ['missing the -f json suffix', [CEPH_BIN, 'auth', 'get', ENTITY]],
156
+ ['-f without json', [CEPH_BIN, 'auth', 'get', ENTITY, '-f', 'plain']],
157
+ ['a relative ceph, not absolute', ['ceph', 'auth', 'get', ENTITY, '-f', 'json']],
158
+ ['quorum_status with an operand', [CEPH_BIN, 'quorum_status', ENTITY, '-f', 'json']],
159
+ ])('refuses %s', (_name, argv) => {
160
+ expect(cephCommandProblem(argv)).toBeString();
161
+ });
162
+ });
163
+
164
+ describe('config: the named list starts empty, so every shape is refused today', () => {
165
+ test.each([
166
+ ['get', [CEPH_BIN, 'config', 'get', 'global', 'mon_allow_pool_delete', '-f', 'json']],
167
+ ['set', [CEPH_BIN, 'config', 'set', 'global', 'mon_allow_pool_delete', 'true', '-f', 'json']],
168
+ ['rm', [CEPH_BIN, 'config', 'rm', 'global', 'mon_allow_pool_delete', '-f', 'json']],
169
+ ])('config %s is refused: not on the named list', (_verb, argv) => {
170
+ const problem = cephCommandProblem(argv);
171
+ expect(problem).toContain('named option list');
172
+ });
173
+
174
+ test('a lockout-class name is refused even if someone tried to add it', () => {
175
+ const problem = cephCommandProblem([
176
+ CEPH_BIN,
177
+ 'config',
178
+ 'get',
179
+ 'mon',
180
+ 'mon_host',
181
+ '-f',
182
+ 'json',
183
+ ]);
184
+ expect(problem).toContain('lockout-class');
185
+ });
186
+
187
+ test('an unbounded `who` is refused', () => {
188
+ const problem = cephCommandProblem([
189
+ CEPH_BIN,
190
+ 'config',
191
+ 'get',
192
+ 'literally anything',
193
+ 'x',
194
+ '-f',
195
+ 'json',
196
+ ]);
197
+ expect(problem).toBeString();
198
+ });
199
+
200
+ test('a config value with a shell metacharacter is refused', () => {
201
+ const argv = [CEPH_BIN, 'config', 'set', 'global', 'x', 'true; rm -rf /', '-f', 'json'];
202
+ expect(cephCommandProblem(argv)).toBeString();
203
+ });
204
+ });
205
+
206
+ describe('K-A4 finding 4: configValueProblem never accepts a flag-shaped value', () => {
207
+ test.each([
208
+ ['a short flag', '-n'],
209
+ ['a file-overwrite flag glued to its argument', '-o/etc/ceph/ceph.conf'],
210
+ ['a long flag with =', '--admin-daemon=/var/run/ceph/ceph-mon.a.asok'],
211
+ ['another long flag', '--conf=/tmp/x'],
212
+ ['the empty string', ''],
213
+ ['a bare dash', '-'],
214
+ ])('refuses %s (%j)', (_name, value) => {
215
+ expect(configValueProblem(value)).toBeString();
216
+ });
217
+
218
+ test('a negative integer is still a plain value, not a flag', () => {
219
+ expect(configValueProblem('-42')).toBeUndefined();
220
+ });
221
+
222
+ test('an ordinary value is accepted', () => {
223
+ expect(configValueProblem('true')).toBeUndefined();
224
+ expect(configValueProblem('3600')).toBeUndefined();
225
+ });
226
+ });