@homeflare/alchemy 0.1.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 (417) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/cloudflare/client.d.ts +45 -0
  4. package/dist/cloudflare/client.d.ts.map +1 -0
  5. package/dist/cloudflare/index.d.ts +11 -0
  6. package/dist/cloudflare/index.d.ts.map +1 -0
  7. package/dist/cloudflare/index.js +129 -0
  8. package/dist/cloudflare/index.js.map +13 -0
  9. package/dist/cloudflare/lock-rules.d.ts +50 -0
  10. package/dist/cloudflare/lock-rules.d.ts.map +1 -0
  11. package/dist/cloudflare/providers.d.ts +19 -0
  12. package/dist/cloudflare/providers.d.ts.map +1 -0
  13. package/dist/cloudflare/r2-bucket-lock.d.ts +100 -0
  14. package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -0
  15. package/dist/forgejo/branch-protection-form.d.ts +19 -0
  16. package/dist/forgejo/branch-protection-form.d.ts.map +1 -0
  17. package/dist/forgejo/branch-protection.d.ts +47 -0
  18. package/dist/forgejo/branch-protection.d.ts.map +1 -0
  19. package/dist/forgejo/client.d.ts +37 -0
  20. package/dist/forgejo/client.d.ts.map +1 -0
  21. package/dist/forgejo/index.d.ts +18 -0
  22. package/dist/forgejo/index.d.ts.map +1 -0
  23. package/dist/forgejo/index.js +479 -0
  24. package/dist/forgejo/index.js.map +21 -0
  25. package/dist/forgejo/org-actions-secrets.d.ts +45 -0
  26. package/dist/forgejo/org-actions-secrets.d.ts.map +1 -0
  27. package/dist/forgejo/org-label.d.ts +46 -0
  28. package/dist/forgejo/org-label.d.ts.map +1 -0
  29. package/dist/forgejo/org-team.d.ts +42 -0
  30. package/dist/forgejo/org-team.d.ts.map +1 -0
  31. package/dist/forgejo/repo-webhook-form.d.ts +27 -0
  32. package/dist/forgejo/repo-webhook-form.d.ts.map +1 -0
  33. package/dist/forgejo/repo-webhook.d.ts +46 -0
  34. package/dist/forgejo/repo-webhook.d.ts.map +1 -0
  35. package/dist/forgejo/repository.d.ts +45 -0
  36. package/dist/forgejo/repository.d.ts.map +1 -0
  37. package/dist/forgejo/resource.d.ts +83 -0
  38. package/dist/forgejo/resource.d.ts.map +1 -0
  39. package/dist/forgejo/team-member.d.ts +39 -0
  40. package/dist/forgejo/team-member.d.ts.map +1 -0
  41. package/dist/forgejo/values.d.ts +24 -0
  42. package/dist/forgejo/values.d.ts.map +1 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +9 -0
  47. package/dist/openbao/auth-role-form.d.ts +38 -0
  48. package/dist/openbao/auth-role-form.d.ts.map +1 -0
  49. package/dist/openbao/auth-role.d.ts +20 -0
  50. package/dist/openbao/auth-role.d.ts.map +1 -0
  51. package/dist/openbao/bao-address.d.ts +72 -0
  52. package/dist/openbao/bao-address.d.ts.map +1 -0
  53. package/dist/openbao/bao-http.d.ts +71 -0
  54. package/dist/openbao/bao-http.d.ts.map +1 -0
  55. package/dist/openbao/bao-status.d.ts +74 -0
  56. package/dist/openbao/bao-status.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-group-scope.d.ts +73 -0
  58. package/dist/openbao/cloudflare-group-scope.d.ts.map +1 -0
  59. package/dist/openbao/cloudflare-parity-catalog.d.ts +28 -0
  60. package/dist/openbao/cloudflare-parity-catalog.d.ts.map +1 -0
  61. package/dist/openbao/cloudflare-parity-snapshot.d.ts +31 -0
  62. package/dist/openbao/cloudflare-parity-snapshot.d.ts.map +1 -0
  63. package/dist/openbao/cloudflare-parity.d.ts +14 -0
  64. package/dist/openbao/cloudflare-parity.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-permission-groups-solve.d.ts +85 -0
  66. package/dist/openbao/cloudflare-permission-groups-solve.d.ts.map +1 -0
  67. package/dist/openbao/cloudflare-permission-groups.d.ts +87 -0
  68. package/dist/openbao/cloudflare-permission-groups.d.ts.map +1 -0
  69. package/dist/openbao/cloudflare-policy.d.ts +65 -0
  70. package/dist/openbao/cloudflare-policy.d.ts.map +1 -0
  71. package/dist/openbao/cloudflare-role-form.d.ts +110 -0
  72. package/dist/openbao/cloudflare-role-form.d.ts.map +1 -0
  73. package/dist/openbao/cloudflare-role-wire.d.ts +27 -0
  74. package/dist/openbao/cloudflare-role-wire.d.ts.map +1 -0
  75. package/dist/openbao/cloudflare-role.d.ts +35 -0
  76. package/dist/openbao/cloudflare-role.d.ts.map +1 -0
  77. package/dist/openbao/cloudflare-roles-config.d.ts +67 -0
  78. package/dist/openbao/cloudflare-roles-config.d.ts.map +1 -0
  79. package/dist/openbao/cloudflare-roles-expand.d.ts +76 -0
  80. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -0
  81. package/dist/openbao/digest.d.ts +24 -0
  82. package/dist/openbao/digest.d.ts.map +1 -0
  83. package/dist/openbao/fake-bao.d.ts +40 -0
  84. package/dist/openbao/fake-bao.d.ts.map +1 -0
  85. package/dist/openbao/forgejo-bootstrap-parse.d.ts +46 -0
  86. package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -0
  87. package/dist/openbao/forgejo-bootstrap.d.ts +29 -0
  88. package/dist/openbao/forgejo-bootstrap.d.ts.map +1 -0
  89. package/dist/openbao/index.d.ts +18 -0
  90. package/dist/openbao/index.d.ts.map +1 -0
  91. package/dist/openbao/index.js +1158 -0
  92. package/dist/openbao/index.js.map +33 -0
  93. package/dist/openbao/mount-form.d.ts +60 -0
  94. package/dist/openbao/mount-form.d.ts.map +1 -0
  95. package/dist/openbao/mount-wire.d.ts +24 -0
  96. package/dist/openbao/mount-wire.d.ts.map +1 -0
  97. package/dist/openbao/mount.d.ts +20 -0
  98. package/dist/openbao/mount.d.ts.map +1 -0
  99. package/dist/openbao/pki-role-form.d.ts +118 -0
  100. package/dist/openbao/pki-role-form.d.ts.map +1 -0
  101. package/dist/openbao/pki-role.d.ts +50 -0
  102. package/dist/openbao/pki-role.d.ts.map +1 -0
  103. package/dist/openbao/policy-assembly.d.ts +13 -0
  104. package/dist/openbao/policy-assembly.d.ts.map +1 -0
  105. package/dist/openbao/policy-wire.d.ts +41 -0
  106. package/dist/openbao/policy-wire.d.ts.map +1 -0
  107. package/dist/openbao/policy.d.ts +83 -0
  108. package/dist/openbao/policy.d.ts.map +1 -0
  109. package/dist/openbao/proxmox-role-form.d.ts +92 -0
  110. package/dist/openbao/proxmox-role-form.d.ts.map +1 -0
  111. package/dist/openbao/proxmox-role.d.ts +47 -0
  112. package/dist/openbao/proxmox-role.d.ts.map +1 -0
  113. package/dist/openbao/ssh-role-form.d.ts +99 -0
  114. package/dist/openbao/ssh-role-form.d.ts.map +1 -0
  115. package/dist/openbao/ssh-role.d.ts +43 -0
  116. package/dist/openbao/ssh-role.d.ts.map +1 -0
  117. package/dist/proxmox/acl.d.ts +72 -0
  118. package/dist/proxmox/acl.d.ts.map +1 -0
  119. package/dist/proxmox/api-token-form.d.ts +59 -0
  120. package/dist/proxmox/api-token-form.d.ts.map +1 -0
  121. package/dist/proxmox/api-token.d.ts +159 -0
  122. package/dist/proxmox/api-token.d.ts.map +1 -0
  123. package/dist/proxmox/backup-job.d.ts +112 -0
  124. package/dist/proxmox/backup-job.d.ts.map +1 -0
  125. package/dist/proxmox/ceph-daemon-form.d.ts +70 -0
  126. package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -0
  127. package/dist/proxmox/ceph-daemon.d.ts +162 -0
  128. package/dist/proxmox/ceph-daemon.d.ts.map +1 -0
  129. package/dist/proxmox/ceph-flag.d.ts +131 -0
  130. package/dist/proxmox/ceph-flag.d.ts.map +1 -0
  131. package/dist/proxmox/ceph-fs-wire.d.ts +106 -0
  132. package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -0
  133. package/dist/proxmox/ceph-fs.d.ts +124 -0
  134. package/dist/proxmox/ceph-fs.d.ts.map +1 -0
  135. package/dist/proxmox/ceph-osd-tree.d.ts +154 -0
  136. package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -0
  137. package/dist/proxmox/ceph-osd-write.d.ts +36 -0
  138. package/dist/proxmox/ceph-osd-write.d.ts.map +1 -0
  139. package/dist/proxmox/ceph-osd.d.ts +102 -0
  140. package/dist/proxmox/ceph-osd.d.ts.map +1 -0
  141. package/dist/proxmox/ceph-pool-form.d.ts +217 -0
  142. package/dist/proxmox/ceph-pool-form.d.ts.map +1 -0
  143. package/dist/proxmox/ceph-pool-settle.d.ts +39 -0
  144. package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -0
  145. package/dist/proxmox/ceph-pool.d.ts +48 -0
  146. package/dist/proxmox/ceph-pool.d.ts.map +1 -0
  147. package/dist/proxmox/client.d.ts +64 -0
  148. package/dist/proxmox/client.d.ts.map +1 -0
  149. package/dist/proxmox/credentials.d.ts +153 -0
  150. package/dist/proxmox/credentials.d.ts.map +1 -0
  151. package/dist/proxmox/firewall-alias-form.d.ts +98 -0
  152. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -0
  153. package/dist/proxmox/firewall-alias.d.ts +141 -0
  154. package/dist/proxmox/firewall-alias.d.ts.map +1 -0
  155. package/dist/proxmox/generated/pbs.d.ts +388 -0
  156. package/dist/proxmox/generated/pbs.d.ts.map +1 -0
  157. package/dist/proxmox/generated/pve.d.ts +4713 -0
  158. package/dist/proxmox/generated/pve.d.ts.map +1 -0
  159. package/dist/proxmox/group.d.ts +104 -0
  160. package/dist/proxmox/group.d.ts.map +1 -0
  161. package/dist/proxmox/ha-resource.d.ts +104 -0
  162. package/dist/proxmox/ha-resource.d.ts.map +1 -0
  163. package/dist/proxmox/ha-rule-form.d.ts +103 -0
  164. package/dist/proxmox/ha-rule-form.d.ts.map +1 -0
  165. package/dist/proxmox/ha-rule.d.ts +156 -0
  166. package/dist/proxmox/ha-rule.d.ts.map +1 -0
  167. package/dist/proxmox/index.d.ts +43 -0
  168. package/dist/proxmox/index.d.ts.map +1 -0
  169. package/dist/proxmox/index.js +2322 -0
  170. package/dist/proxmox/index.js.map +70 -0
  171. package/dist/proxmox/lease-cache.d.ts +98 -0
  172. package/dist/proxmox/lease-cache.d.ts.map +1 -0
  173. package/dist/proxmox/lxc.d.ts +59 -0
  174. package/dist/proxmox/lxc.d.ts.map +1 -0
  175. package/dist/proxmox/members.d.ts +56 -0
  176. package/dist/proxmox/members.d.ts.map +1 -0
  177. package/dist/proxmox/metric-server-form.d.ts +53 -0
  178. package/dist/proxmox/metric-server-form.d.ts.map +1 -0
  179. package/dist/proxmox/metric-server-otel.d.ts +49 -0
  180. package/dist/proxmox/metric-server-otel.d.ts.map +1 -0
  181. package/dist/proxmox/metric-server.d.ts +123 -0
  182. package/dist/proxmox/metric-server.d.ts.map +1 -0
  183. package/dist/proxmox/network-apply-read.d.ts +93 -0
  184. package/dist/proxmox/network-apply-read.d.ts.map +1 -0
  185. package/dist/proxmox/network-apply.d.ts +102 -0
  186. package/dist/proxmox/network-apply.d.ts.map +1 -0
  187. package/dist/proxmox/node-network-form.d.ts +128 -0
  188. package/dist/proxmox/node-network-form.d.ts.map +1 -0
  189. package/dist/proxmox/node-network.d.ts +173 -0
  190. package/dist/proxmox/node-network.d.ts.map +1 -0
  191. package/dist/proxmox/notification-target-form.d.ts +44 -0
  192. package/dist/proxmox/notification-target-form.d.ts.map +1 -0
  193. package/dist/proxmox/notification-target.d.ts +146 -0
  194. package/dist/proxmox/notification-target.d.ts.map +1 -0
  195. package/dist/proxmox/pbs-datastore-form.d.ts +147 -0
  196. package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -0
  197. package/dist/proxmox/pbs-datastore-guard.d.ts +55 -0
  198. package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -0
  199. package/dist/proxmox/pbs-datastore.d.ts +140 -0
  200. package/dist/proxmox/pbs-datastore.d.ts.map +1 -0
  201. package/dist/proxmox/pbs-prune-job-form.d.ts +97 -0
  202. package/dist/proxmox/pbs-prune-job-form.d.ts.map +1 -0
  203. package/dist/proxmox/pbs-prune-job.d.ts +168 -0
  204. package/dist/proxmox/pbs-prune-job.d.ts.map +1 -0
  205. package/dist/proxmox/pbs-sync-job-form.d.ts +83 -0
  206. package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -0
  207. package/dist/proxmox/pbs-sync-job.d.ts +246 -0
  208. package/dist/proxmox/pbs-sync-job.d.ts.map +1 -0
  209. package/dist/proxmox/pbs-verify-job-form.d.ts +114 -0
  210. package/dist/proxmox/pbs-verify-job-form.d.ts.map +1 -0
  211. package/dist/proxmox/pbs-verify-job.d.ts +145 -0
  212. package/dist/proxmox/pbs-verify-job.d.ts.map +1 -0
  213. package/dist/proxmox/pool.d.ts +42 -0
  214. package/dist/proxmox/pool.d.ts.map +1 -0
  215. package/dist/proxmox/qemu.d.ts +46 -0
  216. package/dist/proxmox/qemu.d.ts.map +1 -0
  217. package/dist/proxmox/replication-job-form.d.ts +71 -0
  218. package/dist/proxmox/replication-job-form.d.ts.map +1 -0
  219. package/dist/proxmox/replication-job.d.ts +162 -0
  220. package/dist/proxmox/replication-job.d.ts.map +1 -0
  221. package/dist/proxmox/resource.d.ts +157 -0
  222. package/dist/proxmox/resource.d.ts.map +1 -0
  223. package/dist/proxmox/role.d.ts +97 -0
  224. package/dist/proxmox/role.d.ts.map +1 -0
  225. package/dist/proxmox/sdn-apply-read.d.ts +80 -0
  226. package/dist/proxmox/sdn-apply-read.d.ts.map +1 -0
  227. package/dist/proxmox/sdn-apply.d.ts +63 -0
  228. package/dist/proxmox/sdn-apply.d.ts.map +1 -0
  229. package/dist/proxmox/sdn-subnet-form.d.ts +111 -0
  230. package/dist/proxmox/sdn-subnet-form.d.ts.map +1 -0
  231. package/dist/proxmox/sdn-subnet.d.ts +161 -0
  232. package/dist/proxmox/sdn-subnet.d.ts.map +1 -0
  233. package/dist/proxmox/sdn-vnet.d.ts +113 -0
  234. package/dist/proxmox/sdn-vnet.d.ts.map +1 -0
  235. package/dist/proxmox/sdn-zone.d.ts +126 -0
  236. package/dist/proxmox/sdn-zone.d.ts.map +1 -0
  237. package/dist/proxmox/storage.d.ts +120 -0
  238. package/dist/proxmox/storage.d.ts.map +1 -0
  239. package/dist/proxmox/user.d.ts +107 -0
  240. package/dist/proxmox/user.d.ts.map +1 -0
  241. package/dist/proxmox/values.d.ts +134 -0
  242. package/dist/proxmox/values.d.ts.map +1 -0
  243. package/dist/proxmox/zfs-pool-write.d.ts +76 -0
  244. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -0
  245. package/dist/proxmox/zfs-pool.d.ts +146 -0
  246. package/dist/proxmox/zfs-pool.d.ts.map +1 -0
  247. package/dist/talos/credentials.d.ts +30 -0
  248. package/dist/talos/credentials.d.ts.map +1 -0
  249. package/dist/talos/index.d.ts +15 -0
  250. package/dist/talos/index.d.ts.map +1 -0
  251. package/dist/talos/index.js +392 -0
  252. package/dist/talos/index.js.map +16 -0
  253. package/dist/talos/kubeconfig.d.ts +53 -0
  254. package/dist/talos/kubeconfig.d.ts.map +1 -0
  255. package/dist/talos/resource.d.ts +32 -0
  256. package/dist/talos/resource.d.ts.map +1 -0
  257. package/dist/talos/talos-bootstrap.d.ts +27 -0
  258. package/dist/talos/talos-bootstrap.d.ts.map +1 -0
  259. package/dist/talos/talos-cluster-health.d.ts +36 -0
  260. package/dist/talos/talos-cluster-health.d.ts.map +1 -0
  261. package/dist/talos/talos-machine-config.d.ts +41 -0
  262. package/dist/talos/talos-machine-config.d.ts.map +1 -0
  263. package/dist/talos/talosctl.d.ts +29 -0
  264. package/dist/talos/talosctl.d.ts.map +1 -0
  265. package/dist/talos/values.d.ts +20 -0
  266. package/dist/talos/values.d.ts.map +1 -0
  267. package/package.json +57 -0
  268. package/src/cloudflare/client.ts +76 -0
  269. package/src/cloudflare/index.ts +10 -0
  270. package/src/cloudflare/lock-rules.ts +60 -0
  271. package/src/cloudflare/providers.ts +23 -0
  272. package/src/cloudflare/r2-bucket-lock.ts +220 -0
  273. package/src/forgejo/branch-protection-form.ts +29 -0
  274. package/src/forgejo/branch-protection.ts +101 -0
  275. package/src/forgejo/client.ts +136 -0
  276. package/src/forgejo/index.ts +17 -0
  277. package/src/forgejo/org-actions-secrets.ts +99 -0
  278. package/src/forgejo/org-label.ts +100 -0
  279. package/src/forgejo/org-team.ts +105 -0
  280. package/src/forgejo/repo-webhook-form.ts +47 -0
  281. package/src/forgejo/repo-webhook.ts +118 -0
  282. package/src/forgejo/repository.ts +126 -0
  283. package/src/forgejo/resource.ts +171 -0
  284. package/src/forgejo/team-member.ts +97 -0
  285. package/src/forgejo/values.test.ts +48 -0
  286. package/src/forgejo/values.ts +61 -0
  287. package/src/index.ts +18 -0
  288. package/src/openbao/auth-role-form.ts +112 -0
  289. package/src/openbao/auth-role.ts +101 -0
  290. package/src/openbao/bao-address.test.ts +89 -0
  291. package/src/openbao/bao-address.ts +97 -0
  292. package/src/openbao/bao-http-gate.test.ts +88 -0
  293. package/src/openbao/bao-http-unix.test.ts +70 -0
  294. package/src/openbao/bao-http.test.ts +161 -0
  295. package/src/openbao/bao-http.ts +185 -0
  296. package/src/openbao/bao-status.test.ts +114 -0
  297. package/src/openbao/bao-status.ts +137 -0
  298. package/src/openbao/cloudflare-group-scope.test.ts +103 -0
  299. package/src/openbao/cloudflare-group-scope.ts +83 -0
  300. package/src/openbao/cloudflare-parity-catalog.ts +104 -0
  301. package/src/openbao/cloudflare-parity-snapshot.ts +84 -0
  302. package/src/openbao/cloudflare-parity.ts +152 -0
  303. package/src/openbao/cloudflare-permission-groups-solve.test.ts +171 -0
  304. package/src/openbao/cloudflare-permission-groups-solve.ts +197 -0
  305. package/src/openbao/cloudflare-permission-groups.test.ts +369 -0
  306. package/src/openbao/cloudflare-permission-groups.ts +299 -0
  307. package/src/openbao/cloudflare-policy.ts +180 -0
  308. package/src/openbao/cloudflare-role-form.test.ts +177 -0
  309. package/src/openbao/cloudflare-role-form.ts +183 -0
  310. package/src/openbao/cloudflare-role-wire.test.ts +132 -0
  311. package/src/openbao/cloudflare-role-wire.ts +41 -0
  312. package/src/openbao/cloudflare-role.ts +189 -0
  313. package/src/openbao/cloudflare-roles-config.ts +230 -0
  314. package/src/openbao/cloudflare-roles-expand-bucket.test.ts +134 -0
  315. package/src/openbao/cloudflare-roles-expand.ts +231 -0
  316. package/src/openbao/digest.ts +26 -0
  317. package/src/openbao/fake-bao.ts +84 -0
  318. package/src/openbao/forgejo-bootstrap-parse.test.ts +76 -0
  319. package/src/openbao/forgejo-bootstrap-parse.ts +90 -0
  320. package/src/openbao/forgejo-bootstrap.ts +181 -0
  321. package/src/openbao/index.ts +17 -0
  322. package/src/openbao/mount-form.ts +147 -0
  323. package/src/openbao/mount-wire.test.ts +108 -0
  324. package/src/openbao/mount-wire.ts +79 -0
  325. package/src/openbao/mount.ts +132 -0
  326. package/src/openbao/pki-role-form.ts +248 -0
  327. package/src/openbao/pki-role.ts +202 -0
  328. package/src/openbao/policy-assembly.test.ts +28 -0
  329. package/src/openbao/policy-assembly.ts +13 -0
  330. package/src/openbao/policy-wire.test.ts +90 -0
  331. package/src/openbao/policy-wire.ts +53 -0
  332. package/src/openbao/policy.ts +212 -0
  333. package/src/openbao/proxmox-role-form.ts +174 -0
  334. package/src/openbao/proxmox-role.ts +214 -0
  335. package/src/openbao/ssh-role-form.ts +248 -0
  336. package/src/openbao/ssh-role.ts +240 -0
  337. package/src/proxmox/acl.ts +267 -0
  338. package/src/proxmox/api-token-form.ts +122 -0
  339. package/src/proxmox/api-token.ts +222 -0
  340. package/src/proxmox/backup-job.ts +213 -0
  341. package/src/proxmox/ceph-daemon-form.ts +84 -0
  342. package/src/proxmox/ceph-daemon.ts +243 -0
  343. package/src/proxmox/ceph-flag.ts +249 -0
  344. package/src/proxmox/ceph-fs-wire.ts +246 -0
  345. package/src/proxmox/ceph-fs.ts +249 -0
  346. package/src/proxmox/ceph-osd-tree.ts +193 -0
  347. package/src/proxmox/ceph-osd-write.ts +76 -0
  348. package/src/proxmox/ceph-osd.ts +244 -0
  349. package/src/proxmox/ceph-pool-form.ts +249 -0
  350. package/src/proxmox/ceph-pool-settle.ts +70 -0
  351. package/src/proxmox/ceph-pool.ts +212 -0
  352. package/src/proxmox/client.test.ts +185 -0
  353. package/src/proxmox/client.ts +240 -0
  354. package/src/proxmox/credentials.test.ts +144 -0
  355. package/src/proxmox/credentials.ts +254 -0
  356. package/src/proxmox/firewall-alias-form.ts +112 -0
  357. package/src/proxmox/firewall-alias.ts +208 -0
  358. package/src/proxmox/generated/pbs.ts +461 -0
  359. package/src/proxmox/generated/pve.ts +7344 -0
  360. package/src/proxmox/group.ts +199 -0
  361. package/src/proxmox/ha-resource.ts +169 -0
  362. package/src/proxmox/ha-rule-form.ts +185 -0
  363. package/src/proxmox/ha-rule.ts +240 -0
  364. package/src/proxmox/index.ts +42 -0
  365. package/src/proxmox/lease-cache.test.ts +163 -0
  366. package/src/proxmox/lease-cache.ts +131 -0
  367. package/src/proxmox/lxc.ts +117 -0
  368. package/src/proxmox/members.test.ts +54 -0
  369. package/src/proxmox/members.ts +174 -0
  370. package/src/proxmox/metric-server-form.ts +128 -0
  371. package/src/proxmox/metric-server-otel.ts +99 -0
  372. package/src/proxmox/metric-server.ts +214 -0
  373. package/src/proxmox/network-apply-read.ts +228 -0
  374. package/src/proxmox/network-apply.ts +244 -0
  375. package/src/proxmox/node-network-form.ts +213 -0
  376. package/src/proxmox/node-network.ts +243 -0
  377. package/src/proxmox/notification-target-form.ts +104 -0
  378. package/src/proxmox/notification-target.ts +222 -0
  379. package/src/proxmox/pbs-datastore-form.ts +248 -0
  380. package/src/proxmox/pbs-datastore-guard.ts +105 -0
  381. package/src/proxmox/pbs-datastore.ts +271 -0
  382. package/src/proxmox/pbs-prune-job-form.ts +208 -0
  383. package/src/proxmox/pbs-prune-job.ts +227 -0
  384. package/src/proxmox/pbs-sync-job-form.ts +234 -0
  385. package/src/proxmox/pbs-sync-job.ts +258 -0
  386. package/src/proxmox/pbs-verify-job-form.ts +131 -0
  387. package/src/proxmox/pbs-verify-job.ts +227 -0
  388. package/src/proxmox/pool.test.ts +42 -0
  389. package/src/proxmox/pool.ts +80 -0
  390. package/src/proxmox/qemu.ts +89 -0
  391. package/src/proxmox/replication-job-form.ts +89 -0
  392. package/src/proxmox/replication-job.ts +248 -0
  393. package/src/proxmox/resource.ts +257 -0
  394. package/src/proxmox/role.ts +161 -0
  395. package/src/proxmox/sdn-apply-read.test.ts +152 -0
  396. package/src/proxmox/sdn-apply-read.ts +189 -0
  397. package/src/proxmox/sdn-apply.ts +154 -0
  398. package/src/proxmox/sdn-subnet-form.ts +156 -0
  399. package/src/proxmox/sdn-subnet.ts +238 -0
  400. package/src/proxmox/sdn-vnet.ts +228 -0
  401. package/src/proxmox/sdn-zone.ts +246 -0
  402. package/src/proxmox/storage.ts +245 -0
  403. package/src/proxmox/user.ts +214 -0
  404. package/src/proxmox/values.test.ts +193 -0
  405. package/src/proxmox/values.ts +203 -0
  406. package/src/proxmox/zfs-pool-write.ts +130 -0
  407. package/src/proxmox/zfs-pool.ts +253 -0
  408. package/src/talos/credentials.ts +136 -0
  409. package/src/talos/index.ts +14 -0
  410. package/src/talos/kubeconfig.ts +170 -0
  411. package/src/talos/resource.ts +31 -0
  412. package/src/talos/talos-bootstrap.ts +109 -0
  413. package/src/talos/talos-cluster-health.ts +119 -0
  414. package/src/talos/talos-machine-config.ts +146 -0
  415. package/src/talos/talosctl.ts +94 -0
  416. package/src/talos/values.test.ts +80 -0
  417. package/src/talos/values.ts +70 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * How a finished OpenBao HTTP exchange reads: a body, a genuinely absent object, or an error.
3
+ *
4
+ * ★ STATUS CODES, NEVER WORDS. The `bao` CLI this replaced reported every outcome as stderr text,
5
+ * and deciding by that text broke twice: every failure once read as absent (a bad token planned 33
6
+ * updates), and then `bao policy read` turned out to say "absent" in different words from
7
+ * `bao read`, so a new policy could not be created (b3c18a33e). The API itself answers 404 for
8
+ * absent, 403 for denied and 503 for sealed — openbao v2.6.2 sdk/logical/response_util.go:
9
+ * RespondErrorCommon (a read that finds nothing is 404; ErrPermissionDenied is 403) and
10
+ * AdjustErrorStatusCode (ErrSealed is 503).
11
+ */
12
+
13
+ export type BaoIntent = 'read' | 'write' | 'delete';
14
+
15
+ /**
16
+ * A failed call, naming the method, the path and OpenBao's own `errors` array.
17
+ *
18
+ * ⛔ IT NEVER CARRIES THE TOKEN, AND IT NEVER CARRIES A RESPONSE BODY IT DID NOT PARSE. It is built
19
+ * from the method, the API path and OpenBao's error strings only — no request, no headers, no
20
+ * `cause` — because an Effect `HttpClientRequest` inspects its headers through a redaction list
21
+ * that does not name `x-vault-token`. A 2xx body can hold a credential, and a foreign error page
22
+ * can echo the request back, so a body that is not OpenBao's is described, never quoted.
23
+ */
24
+ export class BaoError extends Error {
25
+ constructor(
26
+ readonly status: number,
27
+ readonly method: string,
28
+ readonly path: string,
29
+ readonly errors: readonly string[],
30
+ ) {
31
+ const code = status === 0 ? 'no response' : String(status);
32
+ super(`OpenBao ${method} /v1/${path} -> ${code}: ${errors.join('; ') || '(no errors given)'}`);
33
+ this.name = 'BaoError';
34
+ }
35
+ }
36
+
37
+ export type Settled =
38
+ | { readonly body: Record<string, unknown> | undefined }
39
+ | { readonly error: BaoError };
40
+
41
+ const objectOf = (text: string): Record<string, unknown> | undefined => {
42
+ try {
43
+ const parsed: unknown = JSON.parse(text);
44
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
45
+ ? (parsed as Record<string, unknown>)
46
+ : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ };
51
+
52
+ /**
53
+ * OpenBao's `errors` array, or undefined for a body that is not OpenBao's.
54
+ *
55
+ * ★ EVERY ERROR OPENBAO WRITES IS `{"errors": [...]}` WITH A JSON CONTENT TYPE, 404 included —
56
+ * sdk/logical/response_util.go RespondError:180-196 — and every entry is a STRING.
57
+ * ⛔ AN ARRAY WITH ANY NON-STRING ENTRY IS NOT OPENBAO. The first version filtered the non-strings
58
+ * out, which turned a JSON gateway's `{"errors":[{"code":1000,"message":"Not Found"}]}` into an
59
+ * empty OpenBao error list — and a 404 carrying it into "absent" on a read and "done" on a delete,
60
+ * the plan-CREATE-over-something-that-exists failure arriving through a wrong BAO_ADDR. Found by
61
+ * grok's review on 2026-09-14. An EMPTY array is still OpenBao's: a plain 404 carries exactly that.
62
+ */
63
+ const errorsOf = (text: string): readonly string[] | undefined => {
64
+ const raw = objectOf(text)?.['errors'];
65
+ return Array.isArray(raw) && raw.every((entry) => typeof entry === 'string')
66
+ ? (raw as string[])
67
+ : undefined;
68
+ };
69
+
70
+ /**
71
+ * Classify one exchange.
72
+ *
73
+ * ★ PURE AND EXPORTED SO THE ONE DECISION EVERY READ AND DELETE HERE RESTS ON CAN BE TESTED WITHOUT
74
+ * A VAULT. It has been wrong in both directions already — every failure once read as absent, and
75
+ * one command's absence wording was assumed for another's — see bao-status.test.ts.
76
+ *
77
+ * ⛔ 404 IS ABSENCE ONLY ON A READ, AND ONLY WHEN OPENBAO SAID IT. MEASURED 2026-09-13 with the CLI,
78
+ * and the discriminator is real but NARROW: a genuinely absent object, read by a token that IS
79
+ * allowed to read that path, is a 404. ⛔ AND THE SAME READ UNDER A NARROWER TOKEN IS A DENIAL:
80
+ * under the agent lane the identical read answered `Code: 403 … permission denied`, because the
81
+ * policy grants named paths and an unnamed one is refused rather than reported missing. So
82
+ * "absent" is only knowable to a caller that could have read it — which is exactly why 403 is an
83
+ * ERROR rather than a shrug. A resource that cannot tell absence from denial plans CREATE for
84
+ * something that exists, which is the failure this estate has now found four separate times.
85
+ * ⚠️ A 404 WHOSE BODY IS NOT `{"errors": […strings]}` IS NOT OPENBAO. A BAO_ADDR pointing at the
86
+ * wrong server — a reverse proxy's HTML 404, a gateway's JSON one — must not read as "nothing".
87
+ *
88
+ * 🔴 THE SHELL HELPER USED TO RETURN `''` FOR EVERY NON-ZERO EXIT, AND THAT IS A LIE WITH
89
+ * CONSEQUENCES. An expired BAO_TOKEN, a sealed server or a missing grant all came back as "the
90
+ * mount is not there", so `diff` answered `update`, `reconcile` ran `secrets enable`, and the plan
91
+ * reported a CREATE on infrastructure that exists. The failure the operator sees is about the
92
+ * wrong thing entirely, at the worst possible moment — a sealed vault. ⚠️ SO 403, 5xx AND ANYTHING
93
+ * ELSE THAT IS NOT 2xx FAILS. The cost is that a genuinely-absent read under a too-narrow token
94
+ * becomes an error instead of a create, and that is the right way round: "I could not tell" is a
95
+ * true statement, and "it is not there" was not.
96
+ *
97
+ * 🔴 THE DELETES ALL USED THE READ HELPER, WHICH DISCARDED THE EXIT CODE. A refused
98
+ * `secrets disable` was reported as a successful delete: the resource left Alchemy's state while
99
+ * the mount stayed live, so the next plan showed nothing and the object was orphaned in silence.
100
+ * ⚠️ ABSENCE IS STILL NOT AN ERROR FOR A DELETE, because a delete must be idempotent: Alchemy
101
+ * retries, and the second attempt legitimately finds nothing. So a delete's 404 is success and
102
+ * its 403 is not.
103
+ * ★ DISABLING A MOUNT THAT IS ALREADY GONE IS NOT EVEN A 404. A review on 2026-09-14 said it failed
104
+ * with `no matching mount` and would wedge a retried delete. The server says otherwise: it answers
105
+ * success for a missing mount so as not to reveal whether one existed — openbao v2.6.2
106
+ * vault/logical_system.go:1211-1216 — and the CLI printed "Disabled the secrets engine (if it
107
+ * existed)" (command/secrets_disable.go:87).
108
+ */
109
+ export const settle = (
110
+ intent: BaoIntent,
111
+ method: string,
112
+ path: string,
113
+ status: number,
114
+ text: string,
115
+ ): Settled => {
116
+ const fail = (errors: readonly string[]) => ({
117
+ error: new BaoError(status, method, path, errors),
118
+ });
119
+ if (status < 200 || status >= 300) {
120
+ const errors = errorsOf(text);
121
+ if (errors === undefined) {
122
+ // ⛔ DESCRIBED, NEVER QUOTED. A proxy or gateway error page can echo the request back, headers
123
+ // and all, so quoting it could carry X-Vault-Token into an error that Alchemy logs (grok,
124
+ // 2026-09-14). mint in house/proxmox/src/credentials.ts already did it this way.
125
+ return fail([`not an OpenBao error body (${String(text.length)} bytes)`]);
126
+ }
127
+ if (status === 404 && intent !== 'write') return { body: undefined };
128
+ return fail(errors);
129
+ }
130
+ // ★ A write or delete answering 204 has nothing to say. A READ with no body has told us nothing,
131
+ // and OpenBao never does that — a read that finds nothing is a 404 (RespondErrorCommon).
132
+ if (text.trim() === '') {
133
+ return intent === 'read' ? fail(['a successful read with an empty body']) : { body: undefined };
134
+ }
135
+ const body = objectOf(text);
136
+ return body === undefined ? fail(['a success body that is not a JSON object']) : { body };
137
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `groups_scope:` — the one thing roles.yaml can say that its RESOURCE cannot.
3
+ *
4
+ * ⛔ THE POINT OF EVERY CASE BELOW IS THAT THE KEY IS LOAD-BEARING. One declaration is expanded
5
+ * twice, with the key and without it, against ONE map that holds BOTH ids of `Logs Read`. If the
6
+ * two runs ever resolve to the same id the key is decoration, and a test that only asserted the
7
+ * override would still pass — so the negative case is asserted beside it, every time.
8
+ *
9
+ * ★ THE IDS ARE THE LIVE ONES, MEASURED 2026-09-15 on `cloudflare-<account>-platform/
10
+ * permission-groups`: `Logs Read` is listed twice, `6a315a56…` at `com.cloudflare.api.account`
11
+ * and `c4a30cd5…` at `com.cloudflare.api.account.zone`. The second is what the four live
12
+ * security roles carry on their ACCOUNT resource, and what
13
+ * house/nix/homeflare-config/scripts/cf-mint-analytics-tokens.py:80 pins as PG_LOGS_ZONE.
14
+ */
15
+ import assert from 'node:assert/strict';
16
+ import { describe, it } from 'node:test';
17
+ import {
18
+ GROUP_SCOPES,
19
+ accountResource,
20
+ groupKey,
21
+ groupScopeNamed,
22
+ scopeOfResource,
23
+ } from './cloudflare-group-scope.ts';
24
+ import { resolvePolicies } from './cloudflare-policy.ts';
25
+ import { RolesConfigError, parseRolesConfig } from './cloudflare-roles-config.ts';
26
+ import { type CloudflareZone, expandAll } from './cloudflare-roles-expand.ts';
27
+
28
+ const LOGS_READ_ACCOUNT = '6a315a56f18441e59ed03352369ae956';
29
+ const LOGS_READ_ZONE = 'c4a30cd58c5d42619c86a3c36c441e2d';
30
+
31
+ /** Both ids of the one name, filed the way the engine source files them. */
32
+ const IDS = new Map([
33
+ [groupKey('Logs Read', GROUP_SCOPES.account), LOGS_READ_ACCOUNT],
34
+ [groupKey('Logs Read', GROUP_SCOPES.zone), LOGS_READ_ZONE],
35
+ ]);
36
+
37
+ const ZONES: Record<string, readonly CloudflareZone[]> = {
38
+ acme: [{ id: 'zone-com', internal: false, name: 'example.com' }],
39
+ };
40
+
41
+ const yaml = (scope: string) => `
42
+ defaults: { namespace: homeflare }
43
+ surfaces:
44
+ security:
45
+ account_roles:
46
+ - { name: logs-read, groups: [Logs Read],${scope} ttl: 15m, max_ttl: 1h, desc: 'L.' }
47
+ accounts:
48
+ acme: { id: acct-acme, zones: all, surfaces: [security] }
49
+ `;
50
+
51
+ /** The single declaration, expanded with whatever `groups_scope:` text is given. */
52
+ const resolved = (scope: string) => {
53
+ const roles = expandAll(parseRolesConfig(yaml(scope)), ZONES).get('cloudflare-acme-security');
54
+ const role = roles?.find((r) => r.name === 'logs-read');
55
+ assert.ok(role, 'logs-read was not expanded');
56
+ const { missing, policies } = resolvePolicies(role.policies, IDS);
57
+ assert.deepEqual(missing, []);
58
+ return policies;
59
+ };
60
+
61
+ describe('groups_scope', () => {
62
+ it('resolves the ZONE id on an ACCOUNT resource when the key says so', () => {
63
+ const [entry] = resolved(' groups_scope: zone,');
64
+ assert.ok(entry);
65
+ // ⛔ THE RESOURCE IS STILL THE ACCOUNT. The key moves the lookup, never the binding — that is
66
+ // Cloudflare's "all zones from an account" (cf-mint-analytics-tokens.py:155-158).
67
+ assert.deepEqual(Object.keys(entry.resources), [accountResource('acct-acme')]);
68
+ assert.deepEqual(entry.groupIds, [LOGS_READ_ZONE]);
69
+ });
70
+
71
+ it('resolves the ACCOUNT id from the same declaration without the key', () => {
72
+ const [entry] = resolved('');
73
+ assert.ok(entry);
74
+ assert.deepEqual(Object.keys(entry.resources), [accountResource('acct-acme')]);
75
+ assert.deepEqual(entry.groupIds, [LOGS_READ_ACCOUNT]);
76
+ assert.notEqual(LOGS_READ_ACCOUNT, LOGS_READ_ZONE);
77
+ });
78
+
79
+ /**
80
+ * ⚠️ THE FIELD IS ABSENT, NOT `undefined`-VALUED, WHEN THE yaml IS SILENT. The declaration is
81
+ * Alchemy props: a key that appeared on all 594 roles would rewrite every stored prop set to
82
+ * restate what the resource already says.
83
+ */
84
+ it('leaves `scope` off every entry that does not declare it', () => {
85
+ const roles = expandAll(parseRolesConfig(yaml('')), ZONES).get('cloudflare-acme-security');
86
+ const entry = roles?.[0]?.policies[0];
87
+ assert.ok(entry);
88
+ assert.equal('scope' in entry, false);
89
+ assert.equal(scopeOfResource(accountResource('acct-acme')), GROUP_SCOPES.account);
90
+ });
91
+
92
+ it('refuses a word that is not one of the three scopes', () => {
93
+ assert.throws(() => parseRolesConfig(yaml(' groups_scope: zonee,')), RolesConfigError);
94
+ // ⛔ AND REFUSES THE NAMESPACE ITSELF. roles.yaml writes the short word; a namespace typed
95
+ // there would be a string any looser check would wave through.
96
+ assert.throws(
97
+ () => parseRolesConfig(yaml(" groups_scope: 'com.cloudflare.api.account.zone',")),
98
+ RolesConfigError,
99
+ );
100
+ assert.equal(groupScopeNamed('bucket'), GROUP_SCOPES.bucket);
101
+ assert.equal(groupScopeNamed('zonee'), undefined);
102
+ });
103
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The three resources a Cloudflare policy entry can name, and the SCOPE its permission groups are
3
+ * looked up at — one vocabulary, both directions.
4
+ *
5
+ * ★ EXTRACTED FROM cloudflare-roles-expand.ts 2026-09-15, which had reached 270 lines (AGENTS.md
6
+ * rule 5, code ≤250). The split is by CONCERN, not by size: expand.ts turns roles.yaml into
7
+ * roles, this answers "which of the two ids named `Logs Read` does this entry mean".
8
+ */
9
+
10
+ /** cfhf.py:172-173. */
11
+ export const zoneResource = (zoneId: string) => `com.cloudflare.api.account.zone.${zoneId}`;
12
+ export const accountResource = (accountId: string) => `com.cloudflare.api.account.${accountId}`;
13
+
14
+ /**
15
+ * One R2 bucket, which is a DIFFERENT RESOURCE NAMESPACE from the two above.
16
+ *
17
+ * ⛔ `com.cloudflare.edge.r2.bucket.…`, NOT `com.cloudflare.api.account.…`, and the three parts are
18
+ * joined by UNDERSCORES inside the last segment — `<accountId>_<jurisdiction>_<bucket>`. Getting
19
+ * the separator wrong produces a syntactically fine resource that matches no bucket, and the
20
+ * token mints successfully and is refused on first use, which reads exactly like a missing
21
+ * permission group. Per Cloudflare's R2 API-token documentation, read 2026-09-15.
22
+ * ⚠️ AND IT ONLY BINDS THE `Bucket Item` GROUPS. `Workers R2 Storage Write` / `… Read` are gated at
23
+ * the ACCOUNT scope; the bucket-scoped pair is `Workers R2 Storage Bucket Item Write` / `… Read`.
24
+ * Pairing an account group with this resource is the same silent 403.
25
+ */
26
+ export const bucketResource = (accountId: string, jurisdiction: string, bucket: string) =>
27
+ `com.cloudflare.edge.r2.bucket.${accountId}_${jurisdiction}_${bucket}`;
28
+
29
+ /**
30
+ * The three scopes this expansion can ask for — the namespaces of the three resources above, which
31
+ * is exactly what the engine reports as a group's `scopes`.
32
+ *
33
+ * ★ MEASURED on `cloudflare-<account>-platform/permission-groups`, 2026-09-15 12:10: 401 groups,
34
+ * every one with exactly ONE scope, over five values — 297 `com.cloudflare.api.account`, 94
35
+ * `com.cloudflare.api.account.zone`, 2 `com.cloudflare.edge.r2.bucket` and four each of
36
+ * `…account.flagship.app` and `com.cloudflare.edge.worker.script`, which no resource here builds.
37
+ * The testing account's list is byte-identical, so the ids are Cloudflare's, not an account's.
38
+ */
39
+ export const GROUP_SCOPES = {
40
+ account: 'com.cloudflare.api.account',
41
+ bucket: 'com.cloudflare.edge.r2.bucket',
42
+ zone: 'com.cloudflare.api.account.zone',
43
+ } as const;
44
+
45
+ /**
46
+ * The short word roles.yaml writes in `groups_scope:` → the namespace: `zone` becomes
47
+ * `com.cloudflare.api.account.zone`. Undefined for a word that is not one of the three KEYS above.
48
+ *
49
+ * ⛔ THE yaml SAYS THE SHORT WORD AND NEVER THE NAMESPACE. A namespace typed into roles.yaml would
50
+ * be accepted by any check that only asked for a string, and a misspelt one resolves no group at
51
+ * all — an unknown-group failure naming a scope nobody wrote on purpose, instead of the line.
52
+ * ⚠️ `Object.entries(…).find`, NOT an index with a narrowing `as`: codex standard 2 bans the cast,
53
+ * and the lookup has three entries.
54
+ */
55
+ export const groupScopeNamed = (word: string): string | undefined =>
56
+ Object.entries(GROUP_SCOPES).find(([name]) => name === word)?.[1];
57
+
58
+ /**
59
+ * The scope the groups of one policy entry are gated at: its resource namespace, the instance id
60
+ * dropped. A zone entry asks for zone groups, an account entry for account groups, a bucket entry
61
+ * for the two `com.cloudflare.edge.r2.bucket` ones.
62
+ *
63
+ * ⛔ ZONE IS TESTED BEFORE ACCOUNT, because `com.cloudflare.api.account.zone.<id>` also starts with
64
+ * the account namespace, and the wrong order would call every zone entry an account one.
65
+ * ⚠️ A RESOURCE IN NO KNOWN NAMESPACE BECOMES ITS OWN SCOPE, which matches no group, so it fails as
66
+ * an unresolvable name naming the scope it asked for — never by borrowing another scope's id.
67
+ * ⚠️ IT IS THE DEFAULT, NOT THE LAST WORD. An entry carrying an explicit `scope` overrides it —
68
+ * see DeclaredPolicy.scope (cloudflare-roles-expand.ts) and resolvePolicies.
69
+ */
70
+ export const scopeOfResource = (resource: string) => {
71
+ const scopes = [GROUP_SCOPES.zone, GROUP_SCOPES.bucket, GROUP_SCOPES.account];
72
+ return scopes.find((scope) => resource.startsWith(`${scope}.`)) ?? resource;
73
+ };
74
+
75
+ /**
76
+ * What a resolved group is filed under.
77
+ *
78
+ * ⛔ A NAME ALONE IS NOT A KEY. MEASURED 2026-09-15 on the live list: SEVEN names are listed twice,
79
+ * once at `com.cloudflare.api.account` and once at `com.cloudflare.api.account.zone` — `Access:
80
+ * Apps and Policies Read` / `… Revoke` / `… Write`, `Disable ESC Read` / `… Write`, `Logs Read`
81
+ * and `Logs Write`. cfhf.py:148 keyed by name and the last one read won in silence.
82
+ */
83
+ export const groupKey = (name: string, scope: string) => `${scope} ${name}`;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The two things apply-roles.py wrote BESIDE the roles, checked against the expansion: each mount's
3
+ * KV catalog document (apply-roles.py:48-82) and each mount's description (apply-roles.py:85-95).
4
+ *
5
+ * ★ WHY THE CATALOG IS CHECKED AT ALL: IT IS THE BOOTSTRAP'S INDEPENDENT WITNESS. Its `permissions`
6
+ * are the NAMES apply-roles.py resolved against Cloudflare's own permission-group list at its last
7
+ * --apply — resolve_groups fails the role on any name Cloudflare does not know
8
+ * (apply-roles.py:29-45, :144-149). So a catalog that agrees with roles.yaml means the names the
9
+ * bootstrap pairs with live IDs were real Cloudflare names then, which closes the blind spot
10
+ * cloudflare-permission-groups-solve.ts names.
11
+ *
12
+ * ⚠️ `generated` IS NOT COMPARED. It is the wall-clock time of the last --apply
13
+ * (apply-roles.py:55), so it differs on every run by construction — a note for whoever declares
14
+ * the catalog next: as written it can never plan noop.
15
+ */
16
+ import type { SnapshotMount } from './cloudflare-parity-snapshot.ts';
17
+ import type { CatalogView, ExpandedRole } from './cloudflare-roles-expand.ts';
18
+
19
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
20
+
21
+ /** The catalog entry apply-roles.py:58-62 would write for a role, in the document's own keys. */
22
+ const catalogEntry = (view: CatalogView) => ({
23
+ description: view.description,
24
+ max_ttl: view.maxTtl,
25
+ permissions: view.permissions,
26
+ ttl: view.ttl,
27
+ zone: view.zone,
28
+ });
29
+
30
+ /** The whole document's non-role fields (apply-roles.py:65-67, :72-76). */
31
+ const expectedFields = (mount: string, declared: readonly ExpandedRole[], count: number) => {
32
+ const [first] = declared;
33
+ return {
34
+ account: first?.account,
35
+ account_id: first?.accountId,
36
+ mint_path: `${mount}/creds/<role>`,
37
+ mount,
38
+ role_count: String(count),
39
+ surface: mount.split('-').at(-1),
40
+ };
41
+ };
42
+
43
+ export const catalogProblems = (
44
+ mount: string,
45
+ declared: readonly ExpandedRole[],
46
+ snapshot: SnapshotMount | undefined,
47
+ ): string[] => {
48
+ const catalog = snapshot?.catalog;
49
+ if (catalog === undefined) return ['no catalog document in the snapshot'];
50
+ const problems: string[] = [];
51
+ const live = snapshot?.roles ?? new Map();
52
+ for (const name of [...live.keys()].sort()) {
53
+ if (!catalog.roles.has(name)) problems.push(`live but not in the catalog: ${name}`);
54
+ }
55
+ for (const name of [...catalog.roles.keys()].sort()) {
56
+ if (!live.has(name)) problems.push(`in the catalog but not live: ${name}`);
57
+ }
58
+ for (const role of declared) {
59
+ const entry = catalog.roles.get(role.name);
60
+ if (entry === undefined) {
61
+ problems.push(`in roles.yaml but not in the catalog: ${role.name}`);
62
+ continue;
63
+ }
64
+ for (const [field, want] of Object.entries(catalogEntry(role.catalog))) {
65
+ if (!same(entry[field], want)) {
66
+ const have = JSON.stringify(entry[field]);
67
+ problems.push(
68
+ `${role.name}: catalog ${field}: want ${JSON.stringify(want)} / have ${have}`,
69
+ );
70
+ }
71
+ }
72
+ }
73
+ const fields = expectedFields(mount, declared, catalog.roles.size);
74
+ for (const [field, want] of Object.entries(fields)) {
75
+ if (!same(catalog.fields[field], want)) {
76
+ const have = JSON.stringify(catalog.fields[field]);
77
+ problems.push(`catalog field ${field}: want ${JSON.stringify(want)} / have ${have}`);
78
+ }
79
+ }
80
+ return problems;
81
+ };
82
+
83
+ /**
84
+ * apply-roles.py:91-95 — `Cloudflare <surface> credentials, <n> roles. Catalog: …`, n being the
85
+ * EXPANDED count, not the live one.
86
+ *
87
+ * ⚠️ `observedCount` IS ADDED BACK IN. The Python expanded over every zone, anyauth included, so its
88
+ * count carries the roles Alchemy now only observes (OBSERVED_ROLE_PREFIXES in cloudflare-zones.ts).
89
+ * Leaving them out would report a description drift that is really a scope decision.
90
+ */
91
+ export const descriptionProblem = (
92
+ mount: string,
93
+ declared: readonly ExpandedRole[],
94
+ snapshot: SnapshotMount | undefined,
95
+ observedCount = 0,
96
+ ): string | undefined => {
97
+ const surface = mount.split('-').at(-1) ?? '';
98
+ const count = declared.length + observedCount;
99
+ const want = `Cloudflare ${surface} credentials, ${String(count)} roles. Catalog: kv/cloudflare/catalog/${mount}`;
100
+ const have = snapshot?.description;
101
+ return have === want
102
+ ? undefined
103
+ : `mount description: want ${JSON.stringify(want)} / have ${JSON.stringify(have)}`;
104
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A directory of `bao read` exports, loaded — the offline stand-in for production that
3
+ * cloudflare/parity.ts checks the declaration against.
4
+ *
5
+ * The layout, as exported on 2026-09-14 (all of it non-secret):
6
+ * <dir>/<mount>/<role>.json `bao read <mount>/roles/<role>` data, `policies` a JSON string
7
+ * <dir>/<mount>/_catalog.json the `kv/cloudflare/catalog/<mount>` document apply-roles.py wrote
8
+ * <dir>/_sys-mounts.json `sys/mounts`, for each mount's description
9
+ *
10
+ * ⛔ READ-ONLY AND OFFLINE. Nothing here opens a socket or reads a credential; every other `_*`
11
+ * file in a mount directory is ignored, whatever it holds.
12
+ */
13
+ import { type LiveCloudflareRole, liveRoleOf } from './cloudflare-role-form.ts';
14
+
15
+ type Json = Readonly<Record<string, unknown>>;
16
+
17
+ export interface CatalogDocument {
18
+ /** Every field of the KV document except `roles`. */
19
+ readonly fields: Json;
20
+ /** `roles`, which apply-roles.py stored as a JSON string (apply-roles.py:68-76), parsed. */
21
+ readonly roles: ReadonlyMap<string, Json>;
22
+ }
23
+
24
+ export interface SnapshotMount {
25
+ readonly mount: string;
26
+ readonly roles: ReadonlyMap<string, LiveCloudflareRole>;
27
+ readonly catalog: CatalogDocument | undefined;
28
+ /** `sys/mounts/<mount>/` description, when the export has one. */
29
+ readonly description: string | undefined;
30
+ }
31
+
32
+ const isJson = (value: unknown): value is Json =>
33
+ typeof value === 'object' && value !== null && !Array.isArray(value);
34
+
35
+ const readJson = async (path: string): Promise<Json | undefined> => {
36
+ const file = Bun.file(path);
37
+ if (!(await file.exists())) return undefined;
38
+ const parsed: unknown = await file.json();
39
+ if (!isJson(parsed)) throw new Error(`${path}: not a JSON object`);
40
+ return parsed;
41
+ };
42
+
43
+ const catalogOf = (doc: Json, path: string): CatalogDocument => {
44
+ const raw = doc['roles'];
45
+ const parsed: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
46
+ if (!isJson(parsed)) throw new Error(`${path}: roles is not a JSON object`);
47
+ const roles = new Map<string, Json>();
48
+ for (const [name, entry] of Object.entries(parsed)) {
49
+ if (!isJson(entry)) throw new Error(`${path}: roles.${name} is not an object`);
50
+ roles.set(name, entry);
51
+ }
52
+ const { roles: _roles, ...fields } = doc;
53
+ return { fields, roles };
54
+ };
55
+
56
+ /** Load every `cloudflare-*` mount directory under `dir`. */
57
+ export const loadSnapshot = async (dir: string): Promise<SnapshotMount[]> => {
58
+ const roles = new Map<string, Map<string, LiveCloudflareRole>>();
59
+ for await (const path of new Bun.Glob('cloudflare-*/*.json').scan({ cwd: dir })) {
60
+ const [mount, file] = path.split('/');
61
+ if (mount === undefined || file === undefined || file.startsWith('_')) continue;
62
+ const data = await readJson(`${dir}/${path}`);
63
+ if (data === undefined) continue;
64
+ const byName = roles.get(mount) ?? new Map<string, LiveCloudflareRole>();
65
+ byName.set(file.replace(/\.json$/, ''), liveRoleOf(data));
66
+ roles.set(mount, byName);
67
+ }
68
+ const sys = await readJson(`${dir}/_sys-mounts.json`);
69
+ const mounts = isJson(sys?.['data']) ? sys['data'] : sys;
70
+ const out: SnapshotMount[] = [];
71
+ for (const mount of [...roles.keys()].sort()) {
72
+ const catalogPath = `${dir}/${mount}/_catalog.json`;
73
+ const catalog = await readJson(catalogPath);
74
+ const entry = mounts?.[`${mount}/`];
75
+ const description = isJson(entry) ? entry['description'] : undefined;
76
+ out.push({
77
+ catalog: catalog === undefined ? undefined : catalogOf(catalog, catalogPath),
78
+ description: typeof description === 'string' ? description : undefined,
79
+ mount,
80
+ roles: roles.get(mount) ?? new Map(),
81
+ });
82
+ }
83
+ return out;
84
+ };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The parity report: roles.yaml's expansion against a snapshot of production, field by field, with
3
+ * names resolved to IDs through source (b) — the same solver `permissionGroupsFromLiveRoles` runs.
4
+ *
5
+ * ★ PURE, SO THE CHECK IS THE PROVIDER'S OWN CODE AND NOT A SECOND OPINION. `differences` is the
6
+ * function diff and reconcile call, `solveGroups` is the function source (b) calls, and the
7
+ * expansion is the one declareCloudflareRoles declares. A report that re-implemented any of them
8
+ * could agree with production while the provider did not.
9
+ *
10
+ * ⛔ EVERY LINE IS A PROBLEM OR SAYS `(none)`. An empty section is printed as `(none)` rather than
11
+ * omitted, so a report that silently skipped a mount cannot pass for one that found nothing.
12
+ */
13
+ import { catalogProblems, descriptionProblem } from './cloudflare-parity-catalog.ts';
14
+ import type { SnapshotMount } from './cloudflare-parity-snapshot.ts';
15
+ import {
16
+ describeProblems,
17
+ observationsOf,
18
+ solveGroups,
19
+ } from './cloudflare-permission-groups-solve.ts';
20
+ import { type WirePolicy, atEveryScope, resolvePolicies } from './cloudflare-policy.ts';
21
+ import { differences } from './cloudflare-role-form.ts';
22
+ import type { ExpandedRole } from './cloudflare-roles-expand.ts';
23
+
24
+ export interface ParityReport {
25
+ readonly lines: string[];
26
+ readonly problems: number;
27
+ }
28
+
29
+ const section = (lines: string[], title: string, items: readonly string[]) => {
30
+ if (items.length === 0) {
31
+ lines.push(` ${title}: (none)`);
32
+ return;
33
+ }
34
+ lines.push(` ${title}:`);
35
+ for (const item of items) lines.push(` ${item}`);
36
+ };
37
+
38
+ /** Source (b) over the snapshot, per account — groups by account, and a summary line each. */
39
+ const solveAccounts = (
40
+ declared: ReadonlyMap<string, readonly ExpandedRole[]>,
41
+ snapshot: readonly SnapshotMount[],
42
+ lines: string[],
43
+ ) => {
44
+ const live = new Map<string, readonly WirePolicy[]>();
45
+ for (const mount of snapshot) {
46
+ for (const [name, role] of mount.roles) {
47
+ if (role.policies !== undefined) live.set(`${mount.mount}/${name}`, role.policies);
48
+ }
49
+ }
50
+ const byAccount = new Map<string, ExpandedRole[]>();
51
+ for (const roles of declared.values()) {
52
+ for (const role of roles) {
53
+ byAccount.set(role.account, [...(byAccount.get(role.account) ?? []), role]);
54
+ }
55
+ }
56
+ const groups = new Map<string, ReadonlyMap<string, string>>();
57
+ let problems = 0;
58
+ lines.push('permission groups — source (b), bootstrapped from live roles:');
59
+ for (const [account, roles] of [...byAccount].sort(([a], [b]) => (a < b ? -1 : 1))) {
60
+ const observations = observationsOf(roles, live);
61
+ const solved = solveGroups(observations);
62
+ const found = describeProblems(solved);
63
+ problems += found.length;
64
+ groups.set(account, solved.groups);
65
+ lines.push(
66
+ ` ${account}: ${String(solved.groups.size)} names resolved from ` +
67
+ `${String(observations.length)} policy entries; ${String(solved.ambiguous.length)} ` +
68
+ `ambiguous, ${String(solved.unresolved.length)} unresolved, ` +
69
+ `${String(solved.contradicted.length)} contradicted`,
70
+ );
71
+ for (const problem of found) lines.push(` ${problem}`);
72
+ }
73
+ return { groups, problems };
74
+ };
75
+
76
+ /**
77
+ * ★ `observedPrefixes` NAMES ROLES THAT ARE READ, NEVER DECLARED OR WRITTEN — anyauth's, by Tim's
78
+ * call on 2026-09-14 ("read only, in case we decide to adopt it later"). A live role under one of
79
+ * them is listed in its own section and is not a problem; one that is NOT under a prefix and not
80
+ * declared still is, so the carve-out cannot hide an unrelated stray.
81
+ */
82
+ export const parityReport = (
83
+ declared: ReadonlyMap<string, readonly ExpandedRole[]>,
84
+ snapshot: readonly SnapshotMount[],
85
+ observedPrefixes: readonly string[] = [],
86
+ ): ParityReport => {
87
+ const lines: string[] = [];
88
+ const solved = solveAccounts(declared, snapshot, lines);
89
+ let problems = solved.problems;
90
+ let compared = 0;
91
+ let identical = 0;
92
+ let observedTotal = 0;
93
+ const isObserved = (name: string) => observedPrefixes.some((prefix) => name.startsWith(prefix));
94
+ const mounts = [...new Set([...declared.keys(), ...snapshot.map((m) => m.mount)])].sort();
95
+ for (const mount of mounts) {
96
+ const roles = declared.get(mount) ?? [];
97
+ const live = snapshot.find((m) => m.mount === mount);
98
+ const liveRoles = live?.roles ?? new Map();
99
+ const names = new Set(roles.map((role) => role.name));
100
+ const onlyDeclared = roles.map((role) => role.name).filter((name) => !liveRoles.has(name));
101
+ const undeclared = [...liveRoles.keys()].filter((name) => !names.has(name)).sort();
102
+ const observed = undeclared.filter(isObserved);
103
+ const onlyLive = undeclared.filter((name) => !isObserved(name));
104
+ observedTotal += observed.length;
105
+ const differing: string[] = [];
106
+ for (const role of roles) {
107
+ const have = liveRoles.get(role.name);
108
+ if (have === undefined) continue;
109
+ compared += 1;
110
+ // ⚠️ `atEveryScope`: the solver reads live roles, which carry ids and no scopes, so the only
111
+ // thing it can say about a name is one id — offered at whatever scope an entry asks for.
112
+ const ids = solved.groups.get(role.account) ?? new Map<string, string>();
113
+ const { missing, policies } = resolvePolicies(role.policies, atEveryScope(ids));
114
+ if (missing.length > 0) {
115
+ differing.push(`${role.name}: unresolved groups ${missing.join(', ')}`);
116
+ continue;
117
+ }
118
+ const found = differences(role, policies, have);
119
+ if (found.length === 0) identical += 1;
120
+ for (const d of found)
121
+ differing.push(`${role.name}: ${d.field}: want ${d.want} / have ${d.have}`);
122
+ }
123
+ const catalog = catalogProblems(mount, roles, live);
124
+ const description = descriptionProblem(mount, roles, live, observed.length);
125
+ const catalogCount = live?.catalog?.roles.size;
126
+ lines.push(
127
+ '',
128
+ `${mount}: ${String(roles.length)} declared, ${String(liveRoles.size)} live, ` +
129
+ `${catalogCount === undefined ? 'no' : String(catalogCount)} in catalog`,
130
+ );
131
+ section(lines, 'only in roles.yaml', onlyDeclared);
132
+ section(lines, 'only live', onlyLive);
133
+ // ★ Listed, never counted: these are read-only by decision, not drift.
134
+ section(lines, 'observed read-only (not declared, never written)', observed);
135
+ section(lines, 'fields differing', differing);
136
+ section(lines, 'catalog', catalog);
137
+ section(lines, 'mount description', description === undefined ? [] : [description]);
138
+ problems +=
139
+ onlyDeclared.length +
140
+ onlyLive.length +
141
+ differing.length +
142
+ catalog.length +
143
+ (description === undefined ? 0 : 1);
144
+ }
145
+ lines.push(
146
+ '',
147
+ `roles compared: ${String(compared)}, identical after resolution: ${String(identical)}`,
148
+ `observed read-only: ${String(observedTotal)}`,
149
+ `${String(problems)} problem(s)`,
150
+ );
151
+ return { lines, problems };
152
+ };