@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,114 @@
1
+ /**
2
+ * Fakes of the stores behind the batch-2 families, for fake-bao: every role engine, JWT auth config
3
+ * and login-MFA enforcements (`wireRoles`), the TOTP method table (`totpMethods`) and the plugin
4
+ * catalog (`pluginCatalog`), behind one answer (`fakeEstate`). As in fake-engines.ts, each keys its
5
+ * objects the way the real server does, because a rename test is only as good as the fake's idea of
6
+ * "the same object".
7
+ *
8
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
9
+ */
10
+ import type { Reply, Seen } from './fake-bao.ts';
11
+ import { ABSENT, type Store, type Stored, lastSegment, store } from './fake-engines.ts';
12
+
13
+ /**
14
+ * The role stores whose server lowercases the name: AppRole (openbao v2.6.2
15
+ * builtin/credential/approle/path_role.go:1485 and :1893), Kubernetes
16
+ * (builtin/credential/kubernetes backend.go:362, path_role.go:250 and :396) and JWT, whose `name`
17
+ * field is a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77; sdk v2.6.2
18
+ * framework/field_data.go:250-255 lowercases it on read). PKI and SSH store the name verbatim. The
19
+ * tests mount all three at their default paths.
20
+ */
21
+ const FOLDED = /^auth\/(approle|jwt|kubernetes)\/role\/[^/]+$/;
22
+
23
+ const keyOf = (path: string): string =>
24
+ FOLDED.test(path) ? path.replace(/[^/]+$/, (name) => name.toLowerCase()) : path;
25
+
26
+ /** Fields a write sends comma-joined that the server reads back as a list. */
27
+ const LISTS = new Set([
28
+ 'allowed_domains',
29
+ 'cn_validations',
30
+ 'ext_key_usage',
31
+ 'key_usage',
32
+ 'token_policies',
33
+ ]);
34
+
35
+ /**
36
+ * The server's typed view of a `k=v` body: `true`, `false` and digit strings become values, and the
37
+ * LISTS split. Arrays, objects and duration strings pass through, and every reader here takes a
38
+ * duration string as well as seconds (`ttlSeconds`).
39
+ */
40
+ const typed = (body: Stored): Stored => {
41
+ const out: Stored = {};
42
+ for (const [field, value] of Object.entries(body)) {
43
+ if (typeof value !== 'string') out[field] = value;
44
+ else if (LISTS.has(field)) out[field] = value.split(',').filter((part) => part !== '');
45
+ else if (value === 'true' || value === 'false') out[field] = value === 'true';
46
+ else out[field] = /^\d+$/.test(value) ? Number(value) : value;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
52
+ export const wireRoles = (): Store =>
53
+ store<Stored>(
54
+ (_path, body) => typed(body),
55
+ (path) => keyOf(path),
56
+ );
57
+
58
+ /**
59
+ * `sys/plugins/catalog/<type>/<name>`. A register names its version in the body; a read or a delete
60
+ * names it in `?version=` (plugin-form.ts versionedPath).
61
+ */
62
+ export const pluginCatalog = (): Store =>
63
+ store<Stored>(
64
+ (path, body) => ({ ...body, builtin: false, name: lastSegment(path.split('?')[0] ?? '') }),
65
+ (path, body) => {
66
+ const version = body?.['version'];
67
+ return typeof version === 'string' && version !== '' ? `${path}?version=${version}` : path;
68
+ },
69
+ );
70
+
71
+ /**
72
+ * `identity/mfa/method/totp`: an upsert by `method_name` (identity/mfa.go:159-230), listed with
73
+ * every other type under `identity/mfa/method?list=true` (mfa-wire.ts). Keyed by the server-made
74
+ * id.
75
+ */
76
+ export const totpMethods = (): Store => {
77
+ const live = new Map<string, Stored>();
78
+ let made = 0;
79
+ const answer = (seen: Seen): Reply => {
80
+ const path = seen.path.replace(/^\/v1\//, '');
81
+ if (seen.method === 'POST') {
82
+ const body = JSON.parse(seen.body) as Stored;
83
+ const name = body['method_name'];
84
+ const found = [...live].find(([, method]) => method['name'] === name)?.[0];
85
+ made += found === undefined ? 1 : 0;
86
+ const id = found ?? `method-${String(made)}`;
87
+ live.set(id, { ...body, id, name, namespace_path: '', type: 'totp' });
88
+ return { json: { data: { method_id: id } }, status: 200 };
89
+ }
90
+ if (seen.method === 'DELETE') {
91
+ live.delete(lastSegment(path));
92
+ return { status: 204 };
93
+ }
94
+ if (path !== 'identity/mfa/method?list=true' || live.size === 0) return ABSENT;
95
+ const info = Object.fromEntries(live);
96
+ return { json: { data: { key_info: info, keys: [...live.keys()] } }, status: 200 };
97
+ };
98
+ return Object.assign(answer, { live });
99
+ };
100
+
101
+ /** All three behind one answer, routed by path, for a stack that mixes the families. */
102
+ export const fakeEstate = () => {
103
+ const roles = wireRoles();
104
+ const plugins = pluginCatalog();
105
+ const totp = totpMethods();
106
+ const answer = (seen: Seen): Reply => {
107
+ if (seen.path.startsWith('/v1/identity/mfa/method')) return totp(seen);
108
+ if (seen.path.startsWith('/v1/sys/plugins/catalog/')) return plugins(seen);
109
+ return roles(seen);
110
+ };
111
+ return Object.assign(answer, { plugins, roles, totp });
112
+ };
113
+
114
+ export type Estate = ReturnType<typeof fakeEstate>;
@@ -2,7 +2,7 @@
2
2
  * Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
3
3
  * plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
4
4
  * real server does. That is the point: a rename test is only as good as the fake's idea of "the
5
- * same object".
5
+ * same object". `store` is shared with fake-engines-roles.ts, which fakes the batch-2 families.
6
6
  *
7
7
  * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
8
  */
@@ -17,26 +17,33 @@ import { trimTrailing } from './mount-path.ts';
17
17
  import { BaoPolicyProvider } from './policy.ts';
18
18
  import { policyKey } from './rename.ts';
19
19
 
20
- type Stored = Record<string, unknown>;
20
+ export type Stored = Record<string, unknown>;
21
21
 
22
22
  /** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
23
23
  export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
24
24
 
25
- const ABSENT: Reply = { json: { errors: [] }, status: 404 };
25
+ export const ABSENT: Reply = { json: { errors: [] }, status: 404 };
26
26
 
27
- const store = (
28
- write: (path: string, body: Record<string, string>, before: Stored | undefined) => Stored,
29
- key: (path: string) => string = (path) => path,
27
+ /**
28
+ * A store of objects by API path. `key` turns a request path (and a write's body) into the key the
29
+ * server would store it under.
30
+ */
31
+ export const store = <B extends Stored = Record<string, string>>(
32
+ write: (path: string, body: B, before: Stored | undefined) => Stored,
33
+ key: (path: string, body?: B) => string = (path) => path,
30
34
  ): Store => {
31
35
  const live = new Map<string, Stored>();
32
36
  const answer = (seen: Seen): Reply => {
33
- const path = key(seen.path.replace(/^\/v1\//, ''));
34
- if (seen.method === 'PUT') {
35
- const body = JSON.parse(seen.body) as Record<string, string>;
37
+ const raw = seen.path.replace(/^\/v1\//, '');
38
+ // ★ `bao write` sends PUT, and the identity endpoints are called with POST. Both write.
39
+ if (seen.method === 'PUT' || seen.method === 'POST') {
40
+ const body = JSON.parse(seen.body) as B;
41
+ const path = key(raw, body);
36
42
  live.set(path, write(path, body, live.get(path)));
37
43
  return { status: 204 };
38
44
  }
39
- // ★ A delete answers 204 whether or not the object existed, as all three do.
45
+ const path = key(raw);
46
+ // ★ A delete answers 204 whether or not the object existed, as every engine here does.
40
47
  if (seen.method === 'DELETE') {
41
48
  live.delete(path);
42
49
  return { status: 204 };
@@ -47,7 +54,7 @@ const store = (
47
54
  return Object.assign(answer, { live });
48
55
  };
49
56
 
50
- const lastSegment = (path: string) => path.split('/').at(-1) ?? '';
57
+ export const lastSegment = (path: string): string => path.split('/').at(-1) ?? '';
51
58
 
52
59
  /**
53
60
  * `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The nine batch-2 families as rows of one table, so a rename test can run each of them through
3
+ * Alchemy's own plan and apply (fake-stack.ts) against one fake (fake-engines-roles.ts). Every row
4
+ * declares a resource whose identity is `name` (for Bao.JwtAuthConfig, the mount), with `knob`, a
5
+ * duration, in a prop that is NOT its identity: the pending Output in "a rename lands with another
6
+ * pending prop".
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
11
+ import type * as Output from 'alchemy/Output';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+ import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
16
+ import type { Reply, Seen } from './fake-bao.ts';
17
+ import { type Estate, fakeEstate } from './fake-engines-roles.ts';
18
+ import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
19
+ import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
20
+ import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
21
+ import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
22
+ import { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
23
+ import { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
24
+ import { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
25
+ import { BaoPlugin, BaoPluginProvider } from './plugin.ts';
26
+ import { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
27
+
28
+ export const familyProviders = Layer.mergeAll(
29
+ BaoAuthRoleProvider(),
30
+ BaoJwtAuthConfigProvider(),
31
+ BaoJwtRoleProvider(),
32
+ BaoKubernetesRoleProvider(),
33
+ BaoMfaLoginEnforcementProvider(),
34
+ BaoMfaTotpMethodProvider(),
35
+ BaoPkiRoleProvider(),
36
+ BaoPluginProvider(),
37
+ BaoSshRoleProvider(),
38
+ FetchHttpClient.layer,
39
+ );
40
+
41
+ /**
42
+ * One stack over a fresh estate: every family's provider, and every call the fake saw — plus
43
+ * `owner`, a SECOND stack over the same fake with its own state: another owner of the same names.
44
+ */
45
+ export const withEstate = (
46
+ body: (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>,
47
+ answer: (estate: Estate) => (seen: Seen) => Reply = (estate) => estate,
48
+ ): Promise<void> => {
49
+ const estate = fakeEstate();
50
+ return withFakeStack(familyProviders, answer(estate), (stack, bao) =>
51
+ body(stack, estate, bao.seen, fakeStack(familyProviders, { BAO_ADDR: bao.address }, 'Owner')),
52
+ );
53
+ };
54
+
55
+ /** The row for one family, by name. */
56
+ export const rowOf = (family: string): Family => {
57
+ const row = FAMILIES.find((each) => each.family === family);
58
+ if (row === undefined) throw new Error(`no fake-families row for ${family}`);
59
+ return row;
60
+ };
61
+
62
+ type Text = string | Output.Output<string>;
63
+ type Removal = typeof RemovalPolicy.destroy;
64
+
65
+ export interface Family {
66
+ readonly family: string;
67
+ /** One resource, logical id `id`, whose identity is `name`. */
68
+ readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
69
+ /** Whether the fake holds the object `name` names. */
70
+ readonly has: (estate: Estate, name: string) => boolean;
71
+ }
72
+
73
+ const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
74
+ estate.roles.live.has(path(name));
75
+
76
+ const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
77
+ const SHA = 'a'.repeat(64);
78
+
79
+ export const FAMILIES: readonly Family[] = [
80
+ {
81
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
82
+ BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
83
+ family: 'Bao.AuthRole',
84
+ has: role((name) => `auth/approle/role/${name}`),
85
+ },
86
+ {
87
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
88
+ BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
89
+ removal(),
90
+ ),
91
+ family: 'Bao.PkiRole',
92
+ has: role((name) => `pki/roles/${name}`),
93
+ },
94
+ {
95
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
96
+ BaoJwtRole(id, {
97
+ boundAudiences: ['https://example.com'],
98
+ name,
99
+ roleType: 'jwt',
100
+ tokenPolicies: ['ci'],
101
+ tokenTtl: knob,
102
+ userClaim: 'sub',
103
+ }).pipe(removal()),
104
+ family: 'Bao.JwtRole',
105
+ has: role((name) => `auth/jwt/role/${name}`),
106
+ },
107
+ {
108
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
109
+ BaoKubernetesRole(id, {
110
+ aliasNameSource: 'serviceaccount_uid',
111
+ boundServiceAccountNames: ['app'],
112
+ boundServiceAccountNamespaces: ['apps'],
113
+ name,
114
+ tokenPolicies: ['app'],
115
+ tokenTtl: knob,
116
+ }).pipe(removal()),
117
+ family: 'Bao.KubernetesRole',
118
+ has: role((name) => `auth/kubernetes/role/${name}`),
119
+ },
120
+ {
121
+ declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
122
+ BaoJwtAuthConfig(id, {
123
+ boundIssuer: knob,
124
+ jwksUrl: 'https://issuer.example.com/keys',
125
+ mount,
126
+ }).pipe(removal()),
127
+ family: 'Bao.JwtAuthConfig',
128
+ has: role((mount) => `auth/${mount}/config`),
129
+ },
130
+ {
131
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
132
+ BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
133
+ family: 'Bao.MfaTotpMethod',
134
+ has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
135
+ },
136
+ {
137
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
138
+ BaoMfaLoginEnforcement(id, {
139
+ authMethodTypes: ['jwt'],
140
+ mfaMethodIds: [knob],
141
+ name,
142
+ }).pipe(removal()),
143
+ family: 'Bao.MfaLoginEnforcement',
144
+ has: role((name) => `identity/mfa/login-enforcement/${name}`),
145
+ },
146
+ {
147
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
148
+ BaoSshRole(id, {
149
+ allowUserCertificates: true,
150
+ allowedUsers: ['ops'],
151
+ maxTtl: '8h',
152
+ name,
153
+ ttl: knob,
154
+ }).pipe(removal()),
155
+ family: 'Bao.SshRole',
156
+ has: role((name) => `ssh/roles/${name}`),
157
+ },
158
+ {
159
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
160
+ BaoPlugin(id, {
161
+ args: [knob],
162
+ command: 'plugin-example',
163
+ name,
164
+ sha256: SHA,
165
+ type: 'secret',
166
+ version: 'v1.0.0',
167
+ }).pipe(removal()),
168
+ family: 'Bao.Plugin',
169
+ has: (estate, name) =>
170
+ estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
171
+ },
172
+ ];
173
+
174
+ /**
175
+ * Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
176
+ * differ, so a generation that lands on the other's name has to write over it.
177
+ */
178
+ export const pairOf =
179
+ (row: Family) =>
180
+ (x: string, y: string): StackBody =>
181
+ Effect.gen(function* () {
182
+ yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
183
+ yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
184
+ });
185
+
186
+ /** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
187
+ export const upstream = (ttl: string) =>
188
+ BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A fake mount table for fake-bao — `sys/mounts` (secrets engines) or `sys/auth` (auth methods):
3
+ * enable, tune, read, the listing a 400 is settled against (mount-wire.ts), and `sys/remount`.
4
+ * Split out of mount-move.test.ts so the engine-level ownership tests drive Bao.Mount and
5
+ * Bao.AuthMethod through the same table.
6
+ *
7
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
+ */
9
+ import type { Reply, Seen } from './fake-bao.ts';
10
+
11
+ type Entry = { type: string; description: string; config: Record<string, unknown> };
12
+
13
+ /** A fresh mount's lease TTLs: 0, "use the system default", as the server reports them. */
14
+ const zero = () => ({ default_lease_ttl: 0, max_lease_ttl: 0 });
15
+
16
+ /** The table keyed `<path>/` as the server keys it; `initial` maps a bare path to its type. */
17
+ export const liveTable = (prefix: string, initial: Record<string, string>) => {
18
+ const entries = new Map<string, Entry>(
19
+ Object.entries(initial).map(([path, type]) => [
20
+ `${path}/`,
21
+ { config: zero(), description: '', type },
22
+ ]),
23
+ );
24
+ const types = () => new Map([...entries].map(([path, entry]) => [path, entry.type]));
25
+ const answer = (seen: Seen): Reply => {
26
+ const path = seen.path.replace(/^\/v1\//, '');
27
+ if (path === 'sys/remount' && seen.method === 'POST') {
28
+ const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
29
+ const key = `${from.replace(/^auth\//, '')}/`;
30
+ const entry = entries.get(key);
31
+ if (entry === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
32
+ entries.delete(key);
33
+ entries.set(`${to.replace(/^auth\//, '')}/`, entry);
34
+ return { json: { data: { migration_id: 'm-1' } }, status: 200 };
35
+ }
36
+ if (path.startsWith('sys/remount/status/')) {
37
+ return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
38
+ }
39
+ if (path === prefix) return { json: { data: Object.fromEntries(types()) }, status: 200 };
40
+ const tune = path.endsWith('/tune');
41
+ const key = `${path.slice(prefix.length + 1).replace(/\/tune$/, '')}/`;
42
+ if (seen.method === 'POST') {
43
+ const body = JSON.parse(seen.body || '{}') as Record<string, unknown>;
44
+ const entry = entries.get(key) ?? {
45
+ config: zero(),
46
+ description: '',
47
+ type: 'enabled-by-test',
48
+ };
49
+ if (!tune && typeof body['type'] === 'string') entry.type = body['type'];
50
+ if (tune) {
51
+ const { description, ...config } = body;
52
+ if (typeof description === 'string') entry.description = description;
53
+ entry.config = { ...entry.config, ...config };
54
+ }
55
+ entries.set(key, entry);
56
+ return { status: 204 };
57
+ }
58
+ if (seen.method === 'DELETE') {
59
+ entries.delete(key);
60
+ return { status: 204 };
61
+ }
62
+ const entry = entries.get(key);
63
+ return entry === undefined
64
+ ? { json: { errors: [`No secret engine mount at ${key}`] }, status: 400 }
65
+ : { json: { data: entry }, status: 200 };
66
+ };
67
+ return {
68
+ answer,
69
+ /** The live table as `<path>/` → type, the shape the move tests compare. */
70
+ get table() {
71
+ return types();
72
+ },
73
+ };
74
+ };
@@ -16,6 +16,8 @@
16
16
  * in the shell running the tests is never read. No provider imports this file.
17
17
  */
18
18
  import * as Alchemy from 'alchemy';
19
+ import { AdoptPolicy } from 'alchemy/AdoptPolicy';
20
+ import { provideFreshArtifactStore } from 'alchemy/Artifacts';
19
21
  import * as Effect from 'effect/Effect';
20
22
  import * as Layer from 'effect/Layer';
21
23
  import type { BaoEnvironment } from './bao-address.ts';
@@ -28,9 +30,12 @@ export type Planned = Readonly<Record<string, string>>;
28
30
  /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
29
31
  export type StackBody = Effect.Effect<unknown, unknown, unknown>;
30
32
 
33
+ /** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
34
+ export type DeployOptions = { readonly adopt?: boolean };
35
+
31
36
  export interface FakeStack {
32
37
  /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
33
- readonly deploy: (body: StackBody) => Promise<Planned>;
38
+ readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
34
39
  }
35
40
 
36
41
  type Node = { readonly action: string };
@@ -72,22 +77,30 @@ export const fakeStack = <ROut, E, RIn>(
72
77
  ) => Effect.Effect<PlanView, unknown, never>;
73
78
  const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
74
79
 
75
- const deploy = (body: StackBody) =>
80
+ /**
81
+ * ★ ONE ARTIFACT STORE ACROSS PLAN AND APPLY, as Deploy.ts's evalStack provides — the channel a
82
+ * diff uses to tell reconcile it resumes an interrupted create (ownership/resume.ts).
83
+ * ★ `--adopt` reaches registration, plan and apply alike, as the Alchemist session provides it.
84
+ */
85
+ const deploy = (body: StackBody, options: DeployOptions = {}) =>
76
86
  Effect.gen(function* () {
77
87
  const compiled = yield* stack(name, { providers, state }, body);
78
88
  return yield* Effect.gen(function* () {
79
89
  const planned = yield* plan(compiled);
80
90
  yield* apply(planned);
81
91
  return actionsOf(planned);
82
- }).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
92
+ }).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
83
93
  }).pipe(
94
+ options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
84
95
  Effect.provideService(Alchemy.Stage, 'test'),
85
96
  Effect.provideService(BaoEnv, env),
86
97
  Effect.provide(state),
87
98
  Effect.scoped,
88
99
  );
89
100
 
90
- return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
101
+ return {
102
+ deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
103
+ };
91
104
  };
92
105
 
93
106
  /**
@@ -80,9 +80,21 @@ describe('hostAppRoles', () => {
80
80
  }
81
81
  });
82
82
 
83
- it('refuses unknown classes, duplicate hosts and names that would blur the separator', () => {
83
+ it('gives a host in two classes one role per class', () => {
84
+ const hosts = [
85
+ { class: 'pve-node', name: 'node-c' },
86
+ { class: 'operator-signer', name: 'node-c' },
87
+ ];
88
+ assert.deepEqual(
89
+ hostAppRoles({ ...INPUT, hosts }).map((role) => role.name),
90
+ ['operator-signer--node-c', 'pve-node--node-c'],
91
+ );
92
+ });
93
+
94
+ it('refuses unknown classes, a host twice in one class and names that blur the separator', () => {
84
95
  const bad = [
85
96
  { class: 'nope', name: 'node-c' },
97
+ { class: 'constructor', name: 'node-d' },
86
98
  { class: 'pve-node', name: 'node-a' },
87
99
  { class: 'pve-node', name: 'node-a' },
88
100
  { class: 'pve-node', name: 'node--x' },
@@ -93,7 +105,8 @@ describe('hostAppRoles', () => {
93
105
  (error: unknown) => {
94
106
  assert.ok(error instanceof Error);
95
107
  assert.match(error.message, /unknown class `nope`/);
96
- assert.match(error.message, /`node-a` is listed twice/);
108
+ assert.match(error.message, /unknown class `constructor`/);
109
+ assert.match(error.message, /`node-a` is listed twice in class `pve-node`/);
97
110
  assert.match(error.message, /`node--x` is not lowercase/);
98
111
  assert.match(error.message, /`Node-Y` is not lowercase/);
99
112
  return true;
@@ -96,22 +96,37 @@ export const hostAppRoles = (input: HostAppRolesInput): readonly BaoAuthRoleProp
96
96
  const problems = Object.entries(input.classes).flatMap(([name, spec]) =>
97
97
  classProblems(name, spec),
98
98
  );
99
+ /**
100
+ * ⛔ DEDUPED ON THE ROLE NAME, NOT THE HOST NAME. A host may sit in several classes (one machine
101
+ * that is both a signer and a node), and each class gives it its own role: `<class>--<host>`
102
+ * differs, so nothing collides. Until 2026-09-21 this keyed on the host alone and refused that
103
+ * estate outright. What it must still refuse is the same host twice in ONE class: two
104
+ * declarations of one role name, so one AppRole that two resources would each claim.
105
+ */
99
106
  const seen = new Set<string>();
100
107
  const roles: BaoAuthRoleProps[] = [];
101
108
  for (const host of input.hosts) {
102
109
  if (!NAME.test(host.name)) {
103
110
  problems.push(`host \`${host.name}\` is not lowercase letters, digits, single hyphens`);
104
111
  }
105
- if (seen.has(host.name)) problems.push(`host \`${host.name}\` is listed twice`);
106
- seen.add(host.name);
107
- const spec = input.classes[host.class];
112
+ const name = hostRoleName(host.class, host.name);
113
+ if (seen.has(name)) {
114
+ problems.push(`host \`${host.name}\` is listed twice in class \`${host.class}\``);
115
+ }
116
+ seen.add(name);
117
+ /**
118
+ * ⛔ OWN KEYS ONLY. `classes['constructor']` is Object's constructor, not undefined, so a host
119
+ * naming it passed as a class with no policies and no secretIdTtl, never checked by
120
+ * classProblems, and wrote a role whose secret_id never expires.
121
+ */
122
+ const spec = Object.hasOwn(input.classes, host.class) ? input.classes[host.class] : undefined;
108
123
  if (spec === undefined) {
109
124
  problems.push(`host \`${host.name}\` names unknown class \`${host.class}\``);
110
125
  continue;
111
126
  }
112
127
  roles.push({
113
128
  bindSecretId: true,
114
- name: hostRoleName(host.class, host.name),
129
+ name,
115
130
  secretIdNumUses: spec.secretIdNumUses ?? 0,
116
131
  secretIdTtl: spec.secretIdTtl,
117
132
  tokenMaxTtl: spec.tokenMaxTtl,
@@ -10,24 +10,32 @@
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
12
12
  * the old one keeps its own until that mount is disabled. There is no identity but the mount.
13
+ * ⛔ A MOVE ONTO A MOUNT WHOSE CONFIG EXISTS FAILS THE PLAN, like every other family's move onto an
14
+ * occupied path (rename-identity.ts). With no delete, nothing is lost to a swap, which MEASURED
15
+ * 2026-09-21 ended with each config where it was declared. But the new generation writes over
16
+ * the config it lands on, which changes who can log in on that mount: an issuer or key set
17
+ * someone else manages, silently replaced. Move through a mount with no config instead.
13
18
  */
14
19
  import { Resource } from 'alchemy';
15
20
  import { isResolved } from 'alchemy/Diff';
16
21
  import * as Provider from 'alchemy/Provider';
17
22
  import * as Effect from 'effect/Effect';
18
23
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
24
+ import { claimFor } from '../ownership/adopt.ts';
25
+ import { provingResumes } from '../ownership/resume.ts';
19
26
  import {
20
27
  type BaoJwtAuthConfigAttributes,
21
28
  type BaoJwtAuthConfigProps,
22
29
  attributesOf,
23
30
  configPath,
24
31
  matches,
25
- mountOf,
26
32
  problems,
27
33
  wouldErase,
28
34
  writeBody,
29
35
  } from './jwt-config-form.ts';
30
- import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
36
+ import { declaredOr } from './rename.ts';
37
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
38
+ import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
31
39
 
32
40
  export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
33
41
 
@@ -43,6 +51,16 @@ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig',
43
51
  defaultRemovalPolicy: 'retain',
44
52
  });
45
53
 
54
+ /** The identity is the mount alone; a trailing `/` is the same mount (`configPath` trims it). */
55
+ const IDENTITY: Identity<BaoJwtAuthConfigAttributes> = {
56
+ declared: (props) => {
57
+ const mount = declaredOr(props, 'mount', 'jwt');
58
+ return mount === undefined ? undefined : configPath({ mount });
59
+ },
60
+ family: 'Bao.JwtAuthConfig',
61
+ recorded: (output) => configPath(output),
62
+ };
63
+
46
64
  export const jwtConfigSpec = (
47
65
  props: BaoJwtAuthConfigProps,
48
66
  ): RoleSpec<BaoJwtAuthConfigAttributes> => ({
@@ -62,24 +80,30 @@ export const BaoJwtAuthConfigProvider = () =>
62
80
  BaoJwtAuthConfig.Provider.of({
63
81
  list: () => Effect.succeed([]),
64
82
 
65
- read: Effect.fn(function* ({ olds }) {
66
- const found = yield* readRoleAt(jwtConfigSpec(olds));
67
- return found?.attributes;
83
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
84
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
85
+ return yield* readOwnedRole({ fqn, instanceId, output }, jwtConfigSpec(olds));
68
86
  }),
69
87
 
70
- diff: Effect.fn(function* ({ news, output }) {
71
- if (output === undefined || !isResolved(news)) return undefined;
72
- if (mountOf(news) !== output.mount) return { action: 'replace' } as const;
88
+ diff: Effect.fn(function* ({ news, olds, output }) {
89
+ // ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
90
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
91
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
92
+ if (output === undefined) return undefined;
93
+ if (move !== undefined) return { action: 'replace' } as const;
94
+ if (!isResolved(news)) return undefined;
73
95
  return { action: yield* planRole(jwtConfigSpec(news)) } as const;
74
96
  }),
75
97
 
76
98
  /** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
77
- reconcile: Effect.fn(function* ({ news }) {
78
- return yield* reconcileRole(jwtConfigSpec(news));
99
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
100
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
101
+ yield* guardRename(IDENTITY, news, output);
102
+ return yield* reconcileRole(jwtConfigSpec(news), claimFor({ fqn, instanceId, output }));
79
103
  }),
80
104
 
81
105
  /** ⚠️ Writes nothing — there is no delete endpoint (header). */
82
106
  delete: () => Effect.void,
83
107
  }),
84
- ),
108
+ ).pipe(Effect.map(provingResumes)),
85
109
  );