@homeflare/alchemy 0.7.0 → 0.10.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 (312) hide show
  1. package/README.md +45 -45
  2. package/dist/caddy/config-lifecycle.d.ts +49 -3
  3. package/dist/caddy/config-lifecycle.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +6 -2
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +11 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.d.ts +2 -1
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +270 -44
  11. package/dist/caddy/index.js.map +15 -11
  12. package/dist/caddy/with-file.d.ts +7 -3
  13. package/dist/caddy/with-file.d.ts.map +1 -1
  14. package/dist/launchd/fake-runner.d.ts +1 -0
  15. package/dist/launchd/fake-runner.d.ts.map +1 -1
  16. package/dist/launchd/fake-sudo.d.ts +51 -0
  17. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  18. package/dist/launchd/host-effect.d.ts +1 -1
  19. package/dist/launchd/host-file-form.d.ts +5 -0
  20. package/dist/launchd/host-file-form.d.ts.map +1 -1
  21. package/dist/launchd/host-file-lifecycle.d.ts +14 -1
  22. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  23. package/dist/launchd/host-file.d.ts +2 -1
  24. package/dist/launchd/host-file.d.ts.map +1 -1
  25. package/dist/launchd/index.d.ts +2 -0
  26. package/dist/launchd/index.d.ts.map +1 -1
  27. package/dist/launchd/index.js +642 -49
  28. package/dist/launchd/index.js.map +24 -14
  29. package/dist/launchd/job-lifecycle.d.ts +8 -1
  30. package/dist/launchd/job-lifecycle.d.ts.map +1 -1
  31. package/dist/launchd/job-preflight.d.ts +13 -1
  32. package/dist/launchd/job-preflight.d.ts.map +1 -1
  33. package/dist/launchd/job-validate.d.ts +2 -0
  34. package/dist/launchd/job-validate.d.ts.map +1 -1
  35. package/dist/launchd/job.d.ts +2 -1
  36. package/dist/launchd/job.d.ts.map +1 -1
  37. package/dist/launchd/local-runner.d.ts +2 -0
  38. package/dist/launchd/local-runner.d.ts.map +1 -1
  39. package/dist/launchd/providers.d.ts +2 -1
  40. package/dist/launchd/providers.d.ts.map +1 -1
  41. package/dist/launchd/runner.d.ts +14 -5
  42. package/dist/launchd/runner.d.ts.map +1 -1
  43. package/dist/launchd/sudo-acl.d.ts +25 -0
  44. package/dist/launchd/sudo-acl.d.ts.map +1 -0
  45. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  46. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  47. package/dist/launchd/sudo-guard.d.ts +83 -0
  48. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  49. package/dist/launchd/sudo-runner.d.ts +60 -0
  50. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  51. package/dist/launchd/sudo-said.d.ts +10 -0
  52. package/dist/launchd/sudo-said.d.ts.map +1 -0
  53. package/dist/launchd/sudo-stage.d.ts +7 -0
  54. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  55. package/dist/openbao/auth-method-reconcile.d.ts +6 -2
  56. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
  57. package/dist/openbao/auth-method.d.ts.map +1 -1
  58. package/dist/openbao/auth-role-form.d.ts +0 -9
  59. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  60. package/dist/openbao/auth-role.d.ts +3 -2
  61. package/dist/openbao/auth-role.d.ts.map +1 -1
  62. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  63. package/dist/openbao/fake-bao.d.ts +18 -0
  64. package/dist/openbao/fake-bao.d.ts.map +1 -1
  65. package/dist/openbao/fake-core.d.ts +39 -0
  66. package/dist/openbao/fake-core.d.ts.map +1 -0
  67. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  68. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  69. package/dist/openbao/fake-engines.d.ts +8 -2
  70. package/dist/openbao/fake-engines.d.ts.map +1 -1
  71. package/dist/openbao/fake-families.d.ts +52 -0
  72. package/dist/openbao/fake-families.d.ts.map +1 -0
  73. package/dist/openbao/fake-mounts.d.ts +16 -0
  74. package/dist/openbao/fake-mounts.d.ts.map +1 -0
  75. package/dist/openbao/fake-stack.d.ts +5 -1
  76. package/dist/openbao/fake-stack.d.ts.map +1 -1
  77. package/dist/openbao/host-approles.d.ts.map +1 -1
  78. package/dist/openbao/index.js +709 -359
  79. package/dist/openbao/index.js.map +32 -25
  80. package/dist/openbao/jwt-config.d.ts +5 -0
  81. package/dist/openbao/jwt-config.d.ts.map +1 -1
  82. package/dist/openbao/jwt-role.d.ts +2 -1
  83. package/dist/openbao/jwt-role.d.ts.map +1 -1
  84. package/dist/openbao/kubernetes-role.d.ts +2 -1
  85. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  86. package/dist/openbao/mfa-enforcement.d.ts +12 -5
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  88. package/dist/openbao/mfa-totp.d.ts +6 -2
  89. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  90. package/dist/openbao/mount-reconcile.d.ts +5 -1
  91. package/dist/openbao/mount-reconcile.d.ts.map +1 -1
  92. package/dist/openbao/mount.d.ts.map +1 -1
  93. package/dist/openbao/pki-role.d.ts.map +1 -1
  94. package/dist/openbao/plugin-reconcile.d.ts +6 -2
  95. package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
  96. package/dist/openbao/plugin.d.ts.map +1 -1
  97. package/dist/openbao/policy.d.ts.map +1 -1
  98. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  99. package/dist/openbao/rename-identity.d.ts +69 -0
  100. package/dist/openbao/rename-identity.d.ts.map +1 -0
  101. package/dist/openbao/rename.d.ts +13 -12
  102. package/dist/openbao/rename.d.ts.map +1 -1
  103. package/dist/openbao/role-reconcile.d.ts +11 -1
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -1
  105. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  106. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  107. package/dist/openbao/ssh-role.d.ts.map +1 -1
  108. package/dist/ownership/adopt.d.ts +52 -0
  109. package/dist/ownership/adopt.d.ts.map +1 -0
  110. package/dist/ownership/probe.d.ts +13 -0
  111. package/dist/ownership/probe.d.ts.map +1 -0
  112. package/dist/ownership/resume.d.ts +40 -0
  113. package/dist/ownership/resume.d.ts.map +1 -0
  114. package/dist/ownership/rows.d.ts +33 -0
  115. package/dist/ownership/rows.d.ts.map +1 -0
  116. package/dist/ownership/whole.d.ts +14 -0
  117. package/dist/ownership/whole.d.ts.map +1 -0
  118. package/dist/proxmox/ceph-pool-form.d.ts +3 -3
  119. package/dist/proxmox/ceph-pool.d.ts.map +1 -1
  120. package/dist/proxmox/fake-bao-env.d.ts +3 -0
  121. package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
  122. package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
  123. package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
  124. package/dist/proxmox/fake-pve.d.ts +38 -0
  125. package/dist/proxmox/fake-pve.d.ts.map +1 -0
  126. package/dist/proxmox/index.d.ts +6 -2
  127. package/dist/proxmox/index.d.ts.map +1 -1
  128. package/dist/proxmox/index.js +970 -186
  129. package/dist/proxmox/index.js.map +22 -9
  130. package/dist/proxmox/lxc-create-form.d.ts +6 -0
  131. package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
  132. package/dist/proxmox/lxc-harness.d.ts +34 -0
  133. package/dist/proxmox/lxc-harness.d.ts.map +1 -0
  134. package/dist/proxmox/lxc-identity.d.ts +18 -0
  135. package/dist/proxmox/lxc-identity.d.ts.map +1 -0
  136. package/dist/proxmox/lxc-judge.d.ts +46 -0
  137. package/dist/proxmox/lxc-judge.d.ts.map +1 -0
  138. package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
  139. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
  140. package/dist/proxmox/lxc-props.d.ts +139 -0
  141. package/dist/proxmox/lxc-props.d.ts.map +1 -0
  142. package/dist/proxmox/lxc-volume.d.ts +29 -0
  143. package/dist/proxmox/lxc-volume.d.ts.map +1 -0
  144. package/dist/proxmox/lxc-wire.d.ts +51 -0
  145. package/dist/proxmox/lxc-wire.d.ts.map +1 -0
  146. package/dist/proxmox/lxc.d.ts +27 -47
  147. package/dist/proxmox/lxc.d.ts.map +1 -1
  148. package/dist/proxmox/network-apply-read.d.ts +1 -1
  149. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  150. package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
  151. package/dist/proxmox/resource.d.ts.map +1 -1
  152. package/dist/proxmox/update-guard.d.ts +27 -0
  153. package/dist/proxmox/update-guard.d.ts.map +1 -0
  154. package/dist/verify/args.d.ts +16 -0
  155. package/dist/verify/args.d.ts.map +1 -0
  156. package/dist/verify/cli.d.ts +3 -0
  157. package/dist/verify/cli.d.ts.map +1 -0
  158. package/dist/verify/cli.js +320 -0
  159. package/dist/verify/cli.js.map +15 -0
  160. package/dist/verify/fake-engine.d.ts +57 -0
  161. package/dist/verify/fake-engine.d.ts.map +1 -0
  162. package/dist/verify/index.d.ts +16 -0
  163. package/dist/verify/index.d.ts.map +1 -0
  164. package/dist/verify/index.js +297 -0
  165. package/dist/verify/index.js.map +14 -0
  166. package/dist/verify/report.d.ts +6 -0
  167. package/dist/verify/report.d.ts.map +1 -0
  168. package/dist/verify/rows.d.ts +57 -0
  169. package/dist/verify/rows.d.ts.map +1 -0
  170. package/dist/verify/spy.d.ts +31 -0
  171. package/dist/verify/spy.d.ts.map +1 -0
  172. package/dist/verify/verify.d.ts +43 -0
  173. package/dist/verify/verify.d.ts.map +1 -0
  174. package/docs/adopt-verify.md +108 -0
  175. package/docs/adopted-deploys.md +85 -0
  176. package/docs/caddy-admin.md +24 -0
  177. package/docs/caddy.md +73 -54
  178. package/docs/credentials.md +15 -0
  179. package/docs/launchd-sudo.md +147 -0
  180. package/docs/launchd.md +14 -7
  181. package/docs/ownership.md +113 -0
  182. package/docs/peers.md +39 -0
  183. package/docs/proxmox-lxc.md +196 -0
  184. package/docs/proxmox.md +12 -0
  185. package/package.json +9 -2
  186. package/src/caddy/adopt-scope.test.ts +82 -0
  187. package/src/caddy/config-adopt.test.ts +235 -0
  188. package/src/caddy/config-lifecycle.test.ts +31 -53
  189. package/src/caddy/config-lifecycle.ts +98 -7
  190. package/src/caddy/config.ts +48 -10
  191. package/src/caddy/fake-caddy.ts +19 -0
  192. package/src/caddy/index.ts +2 -1
  193. package/src/caddy/providers.test.ts +8 -23
  194. package/src/caddy/with-file.ts +7 -3
  195. package/src/launchd/adopt-apply.test.ts +166 -0
  196. package/src/launchd/fake-runner.ts +26 -4
  197. package/src/launchd/fake-sudo.ts +136 -0
  198. package/src/launchd/host-effect.ts +1 -1
  199. package/src/launchd/host-file-form.ts +10 -2
  200. package/src/launchd/host-file-lifecycle.ts +41 -15
  201. package/src/launchd/host-file.ts +13 -4
  202. package/src/launchd/index.ts +2 -0
  203. package/src/launchd/job-lifecycle.ts +22 -14
  204. package/src/launchd/job-preflight.ts +21 -3
  205. package/src/launchd/job-validate.ts +1 -1
  206. package/src/launchd/job.ts +13 -4
  207. package/src/launchd/local-runner.ts +6 -3
  208. package/src/launchd/providers.test.ts +3 -0
  209. package/src/launchd/providers.ts +2 -1
  210. package/src/launchd/runner.ts +14 -5
  211. package/src/launchd/sudo-acl.ts +68 -0
  212. package/src/launchd/sudo-allowlist.test.ts +191 -0
  213. package/src/launchd/sudo-allowlist.ts +213 -0
  214. package/src/launchd/sudo-chain.test.ts +121 -0
  215. package/src/launchd/sudo-guard.test.ts +100 -0
  216. package/src/launchd/sudo-guard.ts +230 -0
  217. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  218. package/src/launchd/sudo-modes.test.ts +135 -0
  219. package/src/launchd/sudo-runner-files.test.ts +240 -0
  220. package/src/launchd/sudo-runner.test.ts +126 -0
  221. package/src/launchd/sudo-runner.ts +216 -0
  222. package/src/launchd/sudo-said.ts +58 -0
  223. package/src/launchd/sudo-stage.ts +28 -0
  224. package/src/openbao/README.md +11 -2
  225. package/src/openbao/REPLACE.md +67 -37
  226. package/src/openbao/adopt-core.test.ts +119 -0
  227. package/src/openbao/adopt-families.test.ts +97 -0
  228. package/src/openbao/adopt-holes.test.ts +168 -0
  229. package/src/openbao/adopt-recovery.test.ts +71 -0
  230. package/src/openbao/auth-method-reconcile.ts +15 -3
  231. package/src/openbao/auth-method.ts +15 -6
  232. package/src/openbao/auth-role-form.test.ts +2 -16
  233. package/src/openbao/auth-role-form.ts +0 -11
  234. package/src/openbao/auth-role.ts +48 -17
  235. package/src/openbao/cloudflare-role.ts +23 -18
  236. package/src/openbao/fake-bao.ts +52 -0
  237. package/src/openbao/fake-core.ts +141 -0
  238. package/src/openbao/fake-engines-roles.ts +114 -0
  239. package/src/openbao/fake-engines.ts +18 -11
  240. package/src/openbao/fake-families.ts +188 -0
  241. package/src/openbao/fake-mounts.ts +74 -0
  242. package/src/openbao/fake-stack.ts +17 -4
  243. package/src/openbao/host-approles.test.ts +15 -2
  244. package/src/openbao/host-approles.ts +19 -4
  245. package/src/openbao/jwt-config.ts +35 -11
  246. package/src/openbao/jwt-role.ts +33 -14
  247. package/src/openbao/kubernetes-role.ts +32 -14
  248. package/src/openbao/mfa-enforcement.ts +42 -13
  249. package/src/openbao/mfa-totp.ts +42 -14
  250. package/src/openbao/mount-move.test.ts +2 -35
  251. package/src/openbao/mount-reconcile.ts +13 -2
  252. package/src/openbao/mount.ts +15 -6
  253. package/src/openbao/pki-role.ts +33 -10
  254. package/src/openbao/plugin-reconcile.ts +16 -5
  255. package/src/openbao/plugin.ts +57 -20
  256. package/src/openbao/policy.ts +24 -11
  257. package/src/openbao/proxmox-role.ts +20 -20
  258. package/src/openbao/rename-adoption.test.ts +95 -0
  259. package/src/openbao/rename-families.test.ts +122 -0
  260. package/src/openbao/rename-identity.test.ts +129 -0
  261. package/src/openbao/rename-identity.ts +117 -0
  262. package/src/openbao/rename.ts +19 -15
  263. package/src/openbao/role-reconcile.ts +23 -0
  264. package/src/openbao/ssh-role-erase.ts +50 -0
  265. package/src/openbao/ssh-role.ts +29 -57
  266. package/src/ownership/adopt.ts +122 -0
  267. package/src/ownership/ownership.test.ts +200 -0
  268. package/src/ownership/probe.ts +65 -0
  269. package/src/ownership/resume.ts +118 -0
  270. package/src/ownership/rows.ts +117 -0
  271. package/src/ownership/whole.test.ts +39 -0
  272. package/src/ownership/whole.ts +60 -0
  273. package/src/proxmox/adopt-noop.test.ts +112 -0
  274. package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
  275. package/src/proxmox/ceph-pool-form.ts +3 -3
  276. package/src/proxmox/ceph-pool.ts +10 -3
  277. package/src/proxmox/fake-bao-env.ts +28 -0
  278. package/src/proxmox/fake-pve-lxc.ts +210 -0
  279. package/src/proxmox/fake-pve.ts +75 -0
  280. package/src/proxmox/index.test.ts +6 -6
  281. package/src/proxmox/index.ts +6 -2
  282. package/src/proxmox/lxc-adopt.test.ts +170 -0
  283. package/src/proxmox/lxc-create-form.ts +76 -0
  284. package/src/proxmox/lxc-create.test.ts +126 -0
  285. package/src/proxmox/lxc-guards.test.ts +158 -0
  286. package/src/proxmox/lxc-harness.ts +150 -0
  287. package/src/proxmox/lxc-identity.ts +43 -0
  288. package/src/proxmox/lxc-judge.test.ts +175 -0
  289. package/src/proxmox/lxc-judge.ts +196 -0
  290. package/src/proxmox/lxc-lifecycle.ts +200 -0
  291. package/src/proxmox/lxc-ownership.test.ts +159 -0
  292. package/src/proxmox/lxc-props.ts +215 -0
  293. package/src/proxmox/lxc-read.test.ts +60 -0
  294. package/src/proxmox/lxc-volume.test.ts +96 -0
  295. package/src/proxmox/lxc-volume.ts +153 -0
  296. package/src/proxmox/lxc-wire.ts +210 -0
  297. package/src/proxmox/lxc.ts +216 -93
  298. package/src/proxmox/network-apply-read.ts +3 -1
  299. package/src/proxmox/pbs-datastore.ts +4 -3
  300. package/src/proxmox/resource.ts +7 -7
  301. package/src/proxmox/update-guard.ts +31 -0
  302. package/src/verify/args.ts +82 -0
  303. package/src/verify/cli.test.ts +86 -0
  304. package/src/verify/cli.ts +49 -0
  305. package/src/verify/fake-engine.ts +158 -0
  306. package/src/verify/index.ts +36 -0
  307. package/src/verify/report.ts +52 -0
  308. package/src/verify/rows.ts +169 -0
  309. package/src/verify/spy.test.ts +141 -0
  310. package/src/verify/spy.ts +149 -0
  311. package/src/verify/verify.test.ts +124 -0
  312. package/src/verify/verify.ts +172 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The nine batch-2 families, renamed through Alchemy's own plan and apply (fake-stack.ts), one row
3
+ * each (fake-families.ts). 🔴 Before rename-identity.ts, MEASURED 2026-09-21 against these fakes:
4
+ * · a swap under destroy was a GREEN deploy that deleted both objects, for Bao.AuthRole,
5
+ * Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole and Bao.Plugin, and a shift left
6
+ * only `c`. Bao.JwtAuthConfig wrote each config over the other (it has no delete), and
7
+ * Bao.MfaLoginEnforcement did the same, then stopped on its two refused deletes;
8
+ * · a rename landing with another pending Output planned `update` for all nine and left the old
9
+ * object live and unrecorded; for Bao.MfaTotpMethod that wrote a second method;
10
+ * · with the identity itself pending, all nine wrote the new object and kept the old.
11
+ * What each family's identity folds or defaults is pinned in rename-identity.test.ts.
12
+ */
13
+ import assert from 'node:assert/strict';
14
+ import { describe, it } from 'node:test';
15
+ import * as Output from 'alchemy/Output';
16
+ import * as Effect from 'effect/Effect';
17
+ import type { Estate } from './fake-engines-roles.ts';
18
+ import { FAMILIES, type Family, pairOf, upstream, withEstate } from './fake-families.ts';
19
+ import { writesOf } from './fake-stack.ts';
20
+
21
+ const OCCUPIED = /would land on an object that already exists/;
22
+ /** Bao.MfaTotpMethod never replaces: every rename is refused (mfa-totp.ts). */
23
+ const STRANDS = /would strand every enrolled secret/;
24
+ const isTotp = (row: Family) => row.family === 'Bao.MfaTotpMethod';
25
+
26
+ const snapshot = (estate: Estate) => [
27
+ new Map(estate.roles.live),
28
+ new Map(estate.plugins.live),
29
+ new Map(estate.totp.live),
30
+ ];
31
+
32
+ /** Another prop of the row pending: the upstream's `tokenTtl` changes in the same deploy. */
33
+ const pendingKnob = (row: Family) => (ttl: string, name: string) =>
34
+ Effect.gen(function* () {
35
+ const up = yield* upstream(ttl);
36
+ yield* row.declare('Subject', name, up.tokenTtl);
37
+ });
38
+
39
+ /** The identity itself pending: `r-15m`, then `r-30m`. */
40
+ const pendingName = (row: Family) => (ttl: string) =>
41
+ Effect.gen(function* () {
42
+ const up = yield* upstream(ttl);
43
+ yield* row.declare('Subject', Output.interpolate`r-${up.tokenTtl}`, '15m');
44
+ });
45
+
46
+ for (const row of FAMILIES) {
47
+ describe(`${row.family}: a rename`, () => {
48
+ const pair = pairOf(row);
49
+ const refusal = isTotp(row) ? STRANDS : OCCUPIED;
50
+
51
+ /**
52
+ * ★ THE CONTROL: the identity a generation RECORDS must be the one its props DECLARE. If the two
53
+ * spellings drift apart (a default mount, a version, a trailing `/`), every later plan reads as
54
+ * a move onto the resource's own object and fails, or replaces it, under destroy, onto itself.
55
+ */
56
+ it('redeployed unchanged is a noop that writes nothing', async () => {
57
+ await withEstate(async (stack, _estate, seen) => {
58
+ await stack.deploy(pair('a', 'b'));
59
+ seen.length = 0;
60
+ assert.deepEqual(await stack.deploy(pair('a', 'b')), { X: 'noop', Y: 'noop' });
61
+ assert.deepEqual(writesOf(seen), []);
62
+ });
63
+ });
64
+
65
+ it('onto a name its sibling holds (a swap, under destroy) writes and deletes nothing', async () => {
66
+ await withEstate(async (stack, estate, seen) => {
67
+ await stack.deploy(pair('a', 'b'));
68
+ const before = snapshot(estate);
69
+ seen.length = 0;
70
+ await assert.rejects(stack.deploy(pair('b', 'a')), refusal);
71
+ assert.deepEqual(writesOf(seen), []);
72
+ assert.deepEqual(snapshot(estate), before);
73
+ });
74
+ });
75
+
76
+ it('onto a name still held while that one moves on (a shift) writes nothing', async () => {
77
+ await withEstate(async (stack, estate, seen) => {
78
+ await stack.deploy(pair('a', 'b'));
79
+ seen.length = 0;
80
+ await assert.rejects(stack.deploy(pair('b', 'c')), refusal);
81
+ assert.deepEqual(writesOf(seen), []);
82
+ assert.ok(row.has(estate, 'a') && row.has(estate, 'b') && !row.has(estate, 'c'));
83
+ });
84
+ });
85
+
86
+ it('that lands with another prop pending is judged at plan, not planned as update', async () => {
87
+ await withEstate(async (stack, estate, seen) => {
88
+ const body = pendingKnob(row);
89
+ await stack.deploy(body('15m', 'a'));
90
+ if (isTotp(row)) {
91
+ seen.length = 0;
92
+ await assert.rejects(stack.deploy(body('30m', 'b')), STRANDS);
93
+ // ★ At plan: not even the upstream's update is applied.
94
+ assert.deepEqual(writesOf(seen), []);
95
+ return;
96
+ }
97
+ assert.deepEqual(await stack.deploy(body('30m', 'b')), {
98
+ Subject: 'replace',
99
+ Upstream: 'update',
100
+ });
101
+ // ★ The default retain keeps the old generation; the new one is written.
102
+ assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
103
+ });
104
+ });
105
+
106
+ it('to a name not known until apply is refused before any write, then judged', async () => {
107
+ await withEstate(async (stack, estate) => {
108
+ const body = pendingName(row);
109
+ await stack.deploy(body('15m'));
110
+ const moved = isTotp(row) ? /renaming from `r-15m`/ : /identity moved from \S*r-15m\S* to/;
111
+ await assert.rejects(stack.deploy(body('30m')), moved);
112
+ assert.ok(!row.has(estate, 'r-30m'));
113
+ if (isTotp(row)) {
114
+ await assert.rejects(stack.deploy(body('30m')), STRANDS);
115
+ return;
116
+ }
117
+ assert.deepEqual(await stack.deploy(body('30m')), { Subject: 'replace', Upstream: 'noop' });
118
+ assert.ok(row.has(estate, 'r-30m'));
119
+ });
120
+ });
121
+ });
122
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What each family counts as "the same object": the case the server folds, the mount an absent prop
3
+ * means, a trailing `/`, and a plugin's version. rename-identity.ts spells an identity twice, as the
4
+ * diff's props DECLARE it and as a generation's attributes RECORD it, and the two must agree, or a
5
+ * plan reads a resource's own object as a move onto somebody else's. Run through Alchemy's own plan
6
+ * and apply (fake-stack.ts) against fake-engines-roles.ts.
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { describe, it } from 'node:test';
12
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
13
+ import { rowOf, withEstate } from './fake-families.ts';
14
+ import { writesOf } from './fake-stack.ts';
15
+ import { BaoJwtAuthConfig } from './jwt-config.ts';
16
+ import { BaoPkiRole } from './pki-role.ts';
17
+ import { BaoPlugin } from './plugin.ts';
18
+ import { foldName } from './rename-identity.ts';
19
+
20
+ const OCCUPIED = /would land on an object that already exists/;
21
+
22
+ describe('a change of case the server folds away is not a rename', () => {
23
+ it('foldName lowercases the name segment only; a mount path keeps its case', () => {
24
+ assert.equal(foldName('Host-Cert'), 'host-cert');
25
+ assert.equal(foldName('auth/K8s/role/App'), 'auth/K8s/role/app');
26
+ });
27
+
28
+ it('Bao.AuthRole: `Host-A` after `host-a` under destroy is a noop, nothing deleted', async () => {
29
+ const row = rowOf('Bao.AuthRole');
30
+ await withEstate(async (stack, estate, seen) => {
31
+ await stack.deploy(row.declare('Role', 'host-a', '15m', RemovalPolicy.destroy));
32
+ seen.length = 0;
33
+ const plan = await stack.deploy(row.declare('Role', 'Host-A', '15m', RemovalPolicy.destroy));
34
+ assert.deepEqual(plan, { Role: 'noop' });
35
+ assert.deepEqual(writesOf(seen), []);
36
+ assert.ok(row.has(estate, 'host-a'));
37
+ });
38
+ });
39
+
40
+ /**
41
+ * ★ Both refuse an upper-case name (`problems`), so the change is refused either way. Folded, it is
42
+ * refused for what it is; compared exactly, the plan read `role/App`, the server folded that to
43
+ * the role's own `role/app`, and the move was refused as landing on an object that exists.
44
+ */
45
+ for (const family of ['Bao.JwtRole', 'Bao.KubernetesRole']) {
46
+ it(`${family}: \`App\` after \`app\` is the same role, refused only for its case`, async () => {
47
+ const row = rowOf(family);
48
+ await withEstate(async (stack, estate, seen) => {
49
+ await stack.deploy(row.declare('Role', 'app', '15m', RemovalPolicy.destroy));
50
+ seen.length = 0;
51
+ const again = stack.deploy(row.declare('Role', 'App', '15m', RemovalPolicy.destroy));
52
+ await assert.rejects(again, /must be lower case/);
53
+ assert.deepEqual(writesOf(seen), []);
54
+ assert.ok(row.has(estate, 'app'));
55
+ });
56
+ });
57
+ }
58
+ });
59
+
60
+ describe('the mount an absent prop means, and a trailing `/`, are the same mount', () => {
61
+ it('Bao.JwtAuthConfig with no `mount` redeploys as a noop: the default is `jwt`', async () => {
62
+ const config = () =>
63
+ BaoJwtAuthConfig('Config', {
64
+ boundIssuer: 'https://issuer.example.com',
65
+ jwksUrl: 'https://issuer.example.com/keys',
66
+ });
67
+ await withEstate(async (stack, estate, seen) => {
68
+ await stack.deploy(config());
69
+ seen.length = 0;
70
+ assert.deepEqual(await stack.deploy(config()), { Config: 'noop' });
71
+ assert.deepEqual(writesOf(seen), []);
72
+ assert.ok(estate.roles.live.has('auth/jwt/config'));
73
+ });
74
+ });
75
+
76
+ it('Bao.PkiRole: `pki/` after the default mount is the same role, a noop', async () => {
77
+ const role = (mount?: string) =>
78
+ BaoPkiRole('Role', {
79
+ allowedDomains: ['example.com'],
80
+ maxTtl: '8760h',
81
+ name: 'web',
82
+ ttl: '15m',
83
+ ...(mount === undefined ? {} : { mount }),
84
+ }).pipe(RemovalPolicy.destroy());
85
+ await withEstate(async (stack, estate, seen) => {
86
+ await stack.deploy(role());
87
+ seen.length = 0;
88
+ assert.deepEqual(await stack.deploy(role('pki/')), { Role: 'noop' });
89
+ assert.deepEqual(writesOf(seen), []);
90
+ assert.ok(estate.roles.live.has('pki/roles/web'));
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('Bao.Plugin: the version is part of the catalog key', () => {
96
+ const CATALOG = 'sys/plugins/catalog/secret/example';
97
+ const plugin = (version: string) =>
98
+ BaoPlugin('Plugin', {
99
+ command: 'plugin-example',
100
+ name: 'example',
101
+ sha256: 'a'.repeat(64),
102
+ type: 'secret',
103
+ version,
104
+ }).pipe(RemovalPolicy.destroy());
105
+
106
+ it('a bump registers the new version, then deregisters the old one', async () => {
107
+ await withEstate(async (stack, estate, seen) => {
108
+ await stack.deploy(plugin('v1.0.0'));
109
+ seen.length = 0;
110
+ assert.deepEqual(await stack.deploy(plugin('v1.1.0')), { Plugin: 'replace' });
111
+ assert.deepEqual(writesOf(seen), [
112
+ `PUT /v1/${CATALOG}`,
113
+ `DELETE /v1/${CATALOG}?version=v1.0.0`,
114
+ ]);
115
+ assert.deepEqual([...estate.plugins.live.keys()], [`${CATALOG}?version=v1.1.0`]);
116
+ });
117
+ });
118
+
119
+ it('a bump onto a version already registered by hand fails the plan', async () => {
120
+ await withEstate(async (stack, estate, seen) => {
121
+ await stack.deploy(plugin('v1.0.0'));
122
+ const byHand = { builtin: false, command: 'plugin-example', version: 'v1.1.0' };
123
+ estate.plugins.live.set(`${CATALOG}?version=v1.1.0`, byHand);
124
+ seen.length = 0;
125
+ await assert.rejects(stack.deploy(plugin('v1.1.0')), OCCUPIED);
126
+ assert.deepEqual(writesOf(seen), []);
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The rename guard every Bao.* family whose identity is a name, a mount, or a catalog key shares:
3
+ * one `Identity` per family, and two steps built from rename.ts, `judgeRename` at the top of `diff`
4
+ * and `guardRename` at the top of `reconcile`. REPLACE.md has the audit behind both.
5
+ *
6
+ * ★ ONE DESCRIPTION OF "WHICH OBJECT", NOT TWELVE COPIES OF THE CHECK. The rename-safety fix went
7
+ * in family by family, and the families it skipped were the ones that kept the bug: nine of them
8
+ * judged the identity only after `isResolved(news)` and never asked whether the new path was
9
+ * taken. 🔴 MEASURED 2026-09-21 through the engine (rename-families.test.ts): two Bao.AuthRole,
10
+ * Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole or Bao.Plugin resources that swapped
11
+ * names under `RemovalPolicy.destroy()` both planned `replace`, each wrote over the other, and
12
+ * both old deletes then removed what had just been written: a green deploy, both objects gone.
13
+ * With any other prop still an Output, all nine planned `update` and left the old object live
14
+ * under no state record; for Bao.MfaTotpMethod that wrote a second method and stranded every
15
+ * enrolment.
16
+ */
17
+ import * as Effect from 'effect/Effect';
18
+ import {
19
+ declaredRolePath,
20
+ declaredString,
21
+ isMoved,
22
+ judgeMove,
23
+ refuseMovedUpdate,
24
+ } from './rename.ts';
25
+
26
+ /** How one family names the object it manages — the thing a rename changes. */
27
+ export interface Identity<A> {
28
+ /** `Bao.JwtRole` etc., for messages. */
29
+ readonly family: string;
30
+ /** The identity a set of props declares; undefined while any part of it is still an Output. */
31
+ readonly declared: (props: unknown) => string | undefined;
32
+ /** The identity a generation's attributes record. */
33
+ readonly recorded: (output: A) => string;
34
+ /** The API path an identity is read at, for the occupied check. Default: the identity. */
35
+ readonly pathOf?: ((identity: string) => string) | undefined;
36
+ /** How the server keys it (`policyKey`). Default: exact. */
37
+ readonly key?: ((identity: string) => string) | undefined;
38
+ }
39
+
40
+ /** A family keyed by `name` alone, read at `pathOf(name)`. */
41
+ export const nameIdentity = <A extends { readonly name: string }>(
42
+ family: string,
43
+ pathOf: (name: string) => string,
44
+ key?: (name: string) => string,
45
+ ): Identity<A> => ({
46
+ declared: (props) => declaredString(props, 'name'),
47
+ family,
48
+ key,
49
+ pathOf,
50
+ recorded: (output) => output.name,
51
+ });
52
+
53
+ /**
54
+ * A role at `rolePath(mount, name)`; the identity is that path. `defaultMount` is what an absent
55
+ * `mount` prop means — undefined when `mount` is required.
56
+ */
57
+ export const roleIdentity = <A extends { readonly mount: string; readonly name: string }>(
58
+ family: string,
59
+ rolePath: (mount: string, name: string) => string,
60
+ defaultMount?: string,
61
+ key?: (path: string) => string,
62
+ ): Identity<A> => ({
63
+ declared: (props) => declaredRolePath(props, rolePath, defaultMount),
64
+ family,
65
+ key,
66
+ recorded: (output) => rolePath(output.mount, output.name),
67
+ });
68
+
69
+ /**
70
+ * ⚠️ THE LAST SEGMENT LOWERCASED: a role store that folds the name and not the mount. openbao v2.6.2
71
+ * AppRole (builtin/credential/approle/path_role.go:1485, :1537) and Kubernetes
72
+ * (builtin/credential/kubernetes/backend.go:362, path_role.go:396) both store `role/<lowercased
73
+ * name>`, and JWT reads its `name` as a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77),
74
+ * while the router folds no case in a mount path. So `Host` → `host` is the same role, never a
75
+ * replace that would delete, under `destroy`, the role it had just written.
76
+ */
77
+ export const foldName = (path: string): string =>
78
+ path.replace(/[^/]+$/, (name) => name.toLowerCase());
79
+
80
+ /**
81
+ * `diff`'s first step, before `isResolved(news)`: the move from the identity this resource last
82
+ * wrote or tried to the declared one, or undefined when both name the same object or either is not
83
+ * known yet. ⛔ Fails the plan when the new identity is already taken (`judgeMove`).
84
+ *
85
+ * ★ `olds` STANDS IN FOR `output` WHILE A CREATE OR A REPLACEMENT IS UNFINISHED, which is what
86
+ * catches reverting a move whose new generation failed (rename.ts, `judgeMove`).
87
+ */
88
+ export const judgeRename = <A>(
89
+ identity: Identity<A>,
90
+ olds: unknown,
91
+ news: unknown,
92
+ output: A | undefined,
93
+ ) =>
94
+ judgeMove(
95
+ identity.family,
96
+ output === undefined ? identity.declared(olds) : identity.recorded(output),
97
+ identity.declared(news),
98
+ identity.pathOf ?? ((path) => path),
99
+ identity.key,
100
+ );
101
+
102
+ /**
103
+ * `reconcile`'s first step: an `update` whose prior attributes name another object dies before any
104
+ * read or write (`refuseMovedUpdate`). Only reached when the diff could not see the new identity.
105
+ */
106
+ export const guardRename = <A>(
107
+ identity: Identity<A>,
108
+ news: unknown,
109
+ output: A | undefined,
110
+ ): Effect.Effect<void> => {
111
+ if (output === undefined) return Effect.void;
112
+ const from = identity.recorded(output);
113
+ const to = identity.declared(news);
114
+ return to !== undefined && isMoved(from, to, identity.key) === true
115
+ ? refuseMovedUpdate(identity.family, from, to)
116
+ : Effect.void;
117
+ };
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Rename safety for the families whose identity is a name, or a mount and a name: Bao.Policy,
3
- * Bao.CloudflareRole and Bao.ProxmoxRole. REPLACE.md has the audit and what each answer does.
2
+ * Rename safety for the families whose identity is a name, a mount and a name, or a catalog key:
3
+ * the primitives. rename-identity.ts builds each family's diff and reconcile step from them, and
4
+ * REPLACE.md has the audit and what each answer does.
4
5
  *
5
6
  * ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
6
7
  * (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
@@ -46,12 +47,26 @@ export const isPendingProp = (news: unknown, key: string): boolean => {
46
47
  return value !== undefined && typeof value !== 'string';
47
48
  };
48
49
 
49
- /** A role's API path from a diff's `news`, once both `mount` and `name` are resolved. */
50
+ /**
51
+ * An optional string prop: `fallback` when it is absent, undefined while it is still an Output.
52
+ * ⚠️ Not `declaredString(…) ?? fallback`, which would read a pending mount as the default mount.
53
+ */
54
+ export const declaredOr = (news: unknown, key: string, fallback: string): string | undefined =>
55
+ isPendingProp(news, key) ? undefined : (declaredString(news, key) ?? fallback);
56
+
57
+ /**
58
+ * A role's API path from a diff's `news`, once both `mount` and `name` are resolved. `defaultMount`
59
+ * is what an absent `mount` means; without one, an absent mount is unknown.
60
+ */
50
61
  export const declaredRolePath = (
51
62
  news: unknown,
52
63
  rolePath: (mount: string, name: string) => string,
64
+ defaultMount?: string,
53
65
  ): string | undefined => {
54
- const mount = declaredString(news, 'mount');
66
+ const mount =
67
+ defaultMount === undefined
68
+ ? declaredString(news, 'mount')
69
+ : declaredOr(news, 'mount', defaultMount);
55
70
  const name = declaredString(news, 'name');
56
71
  return mount === undefined || name === undefined ? undefined : rolePath(mount, name);
57
72
  };
@@ -85,17 +100,6 @@ export const isMoved = (
85
100
  key: (identity: string) => string = exact,
86
101
  ): boolean | undefined => (declared === undefined ? undefined : key(stored) !== key(declared));
87
102
 
88
- /**
89
- * The role path a resource last wrote, or last tried to: its attributes once a write finished, else
90
- * the props of the create or replacement that did not (`output` is undefined for both).
91
- */
92
- export const triedRolePath = (
93
- output: { readonly mount: string; readonly name: string } | undefined,
94
- olds: unknown,
95
- rolePath: (mount: string, name: string) => string,
96
- ): string | undefined =>
97
- output === undefined ? declaredRolePath(olds, rolePath) : rolePath(output.mount, output.name);
98
-
99
103
  /**
100
104
  * The diff's first question, asked before `isResolved(news)`: the move from the identity this
101
105
  * resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import * as Effect from 'effect/Effect';
17
17
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import type { Claim, Owner } from '../ownership/adopt.ts';
19
+ import { ownedRead } from '../ownership/probe.ts';
18
20
  import { baoRead, baoWrite } from './bao-http.ts';
19
21
  import type { BaoError } from './bao-status.ts';
20
22
 
@@ -40,6 +42,21 @@ export const readRoleAt = <A>(
40
42
  live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
41
43
  );
42
44
 
45
+ /**
46
+ * `read`: the live attributes — `Unowned` with no state unless our own interrupted create wrote
47
+ * them, which is `planRole`'s `noop` against that create's props (ownership/probe.ts).
48
+ */
49
+ export const readOwnedRole = <A extends object>(
50
+ ask: Owner,
51
+ spec: RoleSpec<A>,
52
+ ): Effect.Effect<A | undefined, BaoError, HttpClient.HttpClient> =>
53
+ Effect.flatMap(readRoleAt(spec), (found) => {
54
+ const ours = Effect.sync(
55
+ () => found !== undefined && spec.problems.length === 0 && spec.matches(found.attributes),
56
+ );
57
+ return ownedRead(ask, found?.attributes, ours);
58
+ });
59
+
43
60
  /** `update` or `noop` — the part of a diff after the identity checks. */
44
61
  export const planRole = <A>(
45
62
  spec: RoleSpec<A>,
@@ -53,12 +70,18 @@ export const planRole = <A>(
53
70
  const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
54
71
  Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
55
72
 
73
+ /**
74
+ * ⛔ `claim` — every provider passes it — refuses, before any write, a create that finds a live
75
+ * object this stack holds no state for (ownership/adopt.ts).
76
+ */
56
77
  export const reconcileRole = <A>(
57
78
  spec: RoleSpec<A>,
79
+ claim?: Claim,
58
80
  ): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
59
81
  Effect.gen(function* () {
60
82
  if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
61
83
  const found = yield* readRoleAt(spec);
84
+ if (found !== undefined && claim !== undefined) yield* claim(`${spec.family} ${spec.path}`);
62
85
  if (found === undefined || !spec.matches(found.attributes)) {
63
86
  const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
64
87
  if (lost.length > 0) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Bao.SshRole's guard against its own full-replace write: the live fields a write would reset that
3
+ * the declaration does not model. Split from ssh-role.ts, whose reconcile calls it.
4
+ */
5
+ import { extMap } from './ssh-role-form.ts';
6
+
7
+ /**
8
+ * Fields this resource deliberately does not model, mapped to the value OpenBao's own write
9
+ * puts there when nobody says otherwise. The templating flags change how a principal is
10
+ * DERIVED at signing time, which is a different kind of decision from which principals
11
+ * exist; the rest are issuance mechanics. MEASURED against all three live roles — every one
12
+ * of them sits on exactly these values, so the guard below fires on nothing that exists.
13
+ */
14
+ const UNMANAGED_DEFAULTS: Readonly<Record<string, unknown>> = {
15
+ algorithm_signer: 'default',
16
+ allow_commas_in_identity_templates: false,
17
+ allow_user_key_ids: false,
18
+ allowed_domains_template: false,
19
+ allowed_users_template: false,
20
+ default_extensions_template: false,
21
+ default_user_template: false,
22
+ issuer_ref: 'default',
23
+ key_id_format: '',
24
+ not_before_duration: 30,
25
+ };
26
+
27
+ /**
28
+ * ⚠️ ABSENT AND EMPTY BOTH COUNT AS "NEVER SET". A role this resource wrote omits these
29
+ * fields entirely, so a stricter test would refuse on the provider's own output and
30
+ * deadlock the update after next.
31
+ */
32
+ const isDefault = (live: Record<string, unknown>, field: string) =>
33
+ live[field] === undefined || live[field] === '' || live[field] === UNMANAGED_DEFAULTS[field];
34
+
35
+ /**
36
+ * ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
37
+ * someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
38
+ * `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
39
+ * survive clock skew, a non-default issuer — loses all of it the moment this resource
40
+ * decides to update. A vanished key-length floor is a security regression that no plan
41
+ * output would ever mention, and a vanished backdating window breaks signing on exactly
42
+ * the skewed host it was added for. So reconcile refuses instead of writing.
43
+ */
44
+ export const wouldErase = (live: Record<string, unknown>): readonly string[] => {
45
+ const lost = Object.keys(UNMANAGED_DEFAULTS).filter((field) => !isDefault(live, field));
46
+ if (Object.keys(extMap(live['allowed_user_key_lengths'])).length > 0) {
47
+ lost.push('allowed_user_key_lengths');
48
+ }
49
+ return lost.sort();
50
+ };