@homeflare/alchemy 0.5.0 → 0.7.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 (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `sys/remount` — move a secrets engine or auth method to a new path WITH its data — and the status
3
+ * polling it needs, because the move finishes after the call returns.
4
+ *
5
+ * ★ READ FROM openbao v2.6.2, NOT RECALLED:
6
+ * · api/sys_mounts.go:95-191 — the CLI's `bao secrets move` POSTs `{from, to}` to sys/remount,
7
+ * takes `data.migration_id`, then GETs sys/remount/status/<id> once a second until
8
+ * `migration_info.status` is `success` or `failure`. This is that loop, bounded.
9
+ * · vault/logical_system.go:1257-1357 — the handler validates, records the migration and
10
+ * answers AT ONCE; a goroutine does the move. So a 200 means "queued", never "moved".
11
+ * · vault/mount.go:630-735 — the mount keeps its UUID, and storage lives under the UUID, so every
12
+ * secret stays. ⛔ BUT EVERY LEASE UNDER THE SOURCE IS REVOKED (`RevokePrefix`, :684): a move of
13
+ * a dynamic-secrets mount kills every credential it has issued. KV has no leases.
14
+ * · vault/logical_system.go:67-69 — `remount` is a sudo path. The status path is not.
15
+ * ⚠️ THE STATUS TRACKER IS IN MEMORY (vault/mount.go:2303-2336, a sync.Map on the active node). A
16
+ * restart or leader change mid-move loses it, and the status read then answers 404. That is not a
17
+ * failure and not a success; `confirm` settles it from the mount tables, or the call fails.
18
+ * ⛔ POLICIES ARE NOT REWRITTEN. Every ACL path naming the old mount keeps naming it.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import { baoCall, baoRead } from './bao-http.ts';
23
+ import type { BaoError } from './bao-status.ts';
24
+
25
+ /** A move OpenBao reported failed, or one this could not confirm. Never carries a token. */
26
+ export class RemountError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'RemountError';
30
+ }
31
+ }
32
+
33
+ export interface RemountOptions {
34
+ /** Milliseconds between status reads. Default 1000 — the CLI's own pace. */
35
+ readonly intervalMillis?: number;
36
+ /** Status reads before giving up. Default 120, so about two minutes. */
37
+ readonly attempts?: number;
38
+ }
39
+
40
+ const record = (value: unknown): Record<string, unknown> | undefined =>
41
+ typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ ? (value as Record<string, unknown>)
43
+ : undefined;
44
+
45
+ /** POST sys/remount and return the migration id. `from`/`to` are full paths (`auth/x` for auth). */
46
+ export const startRemount = (
47
+ from: string,
48
+ to: string,
49
+ ): Effect.Effect<string, BaoError | RemountError, HttpClient.HttpClient> =>
50
+ Effect.flatMap(baoCall('write', 'POST', 'sys/remount', { from, to }), (body) => {
51
+ const id = record(body?.['data'])?.['migration_id'];
52
+ return typeof id === 'string' && id !== ''
53
+ ? Effect.succeed(id)
54
+ : Effect.fail(new RemountError(`sys/remount ${from} -> ${to} answered no migration_id.`));
55
+ });
56
+
57
+ /** `in-progress`, `success`, `failure` — or undefined when the server no longer knows the id. */
58
+ export const remountStatus = (
59
+ id: string,
60
+ ): Effect.Effect<string | undefined, BaoError, HttpClient.HttpClient> =>
61
+ Effect.map(baoRead(`sys/remount/status/${encodeURIComponent(id)}`), (data) => {
62
+ if (data === undefined) return undefined;
63
+ const status = record(data['migration_info'])?.['status'];
64
+ return typeof status === 'string' ? status : 'unknown';
65
+ });
66
+
67
+ /**
68
+ * Move `from` to `to` and wait until OpenBao says it is done.
69
+ *
70
+ * @param confirm answers "does the mount table show the move finished?" — asked only when the
71
+ * status tracker has forgotten the id. True is success; false fails.
72
+ */
73
+ export const remountAndWait = (
74
+ from: string,
75
+ to: string,
76
+ confirm: Effect.Effect<boolean, BaoError, HttpClient.HttpClient>,
77
+ options: RemountOptions = {},
78
+ ): Effect.Effect<void, BaoError | RemountError, HttpClient.HttpClient> =>
79
+ Effect.gen(function* () {
80
+ const interval = options.intervalMillis ?? 1000;
81
+ const attempts = Math.max(1, options.attempts ?? 120);
82
+ const id = yield* startRemount(from, to);
83
+ const named = `sys/remount ${from} -> ${to} (migration ${id})`;
84
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
85
+ const status = yield* remountStatus(id);
86
+ if (status === 'success') return;
87
+ if (status === 'failure') {
88
+ return yield* Effect.fail(
89
+ new RemountError(
90
+ `${named} FAILED. The server log carries the reason, tagged with that migration_id. ` +
91
+ 'Read sys/mounts (or sys/auth) before retrying: the source may be tainted.',
92
+ ),
93
+ );
94
+ }
95
+ if (status === undefined) {
96
+ if (yield* confirm) return;
97
+ return yield* Effect.fail(
98
+ new RemountError(
99
+ `${named}: the server no longer knows the migration (its tracker is in memory, lost ` +
100
+ 'on a restart or leader change), and the mount table does not show the move done.',
101
+ ),
102
+ );
103
+ }
104
+ if (attempt < attempts) yield* Effect.sleep(interval);
105
+ }
106
+ return yield* Effect.fail(
107
+ new RemountError(
108
+ `${named} is still in progress after ${String(attempts)} status reads. Do not re-run ` +
109
+ `until sys/remount/status/${id} settles.`,
110
+ ),
111
+ );
112
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A move onto an object that already exists, through Alchemy's own plan and apply (fake-stack.ts).
3
+ * Before `judgeMove` read the target, the first test below was a GREEN deploy that deleted both
4
+ * policies: each new generation wrote over the other's, and each old generation's delete then
5
+ * removed what the other had just written (rename.ts has the measurement).
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import { describe, it } from 'node:test';
9
+ import * as Output from 'alchemy/Output';
10
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+ import { groupKey } from './cloudflare-group-scope.ts';
15
+ import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
16
+ import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
17
+ import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
18
+ import { type StackBody, withFakeStack, writesOf } from './fake-stack.ts';
19
+ import { BaoPolicy } from './policy.ts';
20
+ import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
21
+
22
+ const OCCUPIED = /would land on an object that already exists/;
23
+
24
+ /** Two policies, `X` and `Y`, each with fragments named after its logical id, under destroy. */
25
+ const pair = (x: string, y: string): StackBody =>
26
+ Effect.gen(function* () {
27
+ yield* BaoPolicy('X', { fragments: '/p/x', name: x }).pipe(RemovalPolicy.destroy());
28
+ yield* BaoPolicy('Y', { fragments: '/p/y', name: y }).pipe(RemovalPolicy.destroy());
29
+ });
30
+
31
+ describe('Bao.Policy: a rename onto a policy that exists fails the plan', () => {
32
+ it('a swap under destroy writes nothing and deletes nothing', async () => {
33
+ const store = aclPolicies();
34
+ await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
35
+ await stack.deploy(pair('a', 'b'));
36
+ const before = new Map(store.live);
37
+ bao.seen.length = 0;
38
+ await assert.rejects(stack.deploy(pair('b', 'a')), OCCUPIED);
39
+ assert.deepEqual(writesOf(bao.seen), []);
40
+ assert.deepEqual(store.live, before);
41
+ });
42
+ });
43
+
44
+ it('a shift (a → b while b → c) is refused for the name still held', async () => {
45
+ const store = aclPolicies();
46
+ await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
47
+ await stack.deploy(pair('a', 'b'));
48
+ bao.seen.length = 0;
49
+ await assert.rejects(stack.deploy(pair('b', 'c')), /Bao\.Policy: a → b would land/);
50
+ assert.deepEqual(writesOf(bao.seen), []);
51
+ });
52
+ });
53
+
54
+ it('reverting a move whose new generation failed is refused while the old name is live', async () => {
55
+ const store = aclPolicies();
56
+ const one = (name: string, dir: string): StackBody =>
57
+ Effect.asVoid(BaoPolicy('X', { fragments: dir, name }).pipe(RemovalPolicy.destroy()));
58
+ await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
59
+ await stack.deploy(one('a', '/p/x'));
60
+ await assert.rejects(stack.deploy(one('b', '/p/empty')), /carry no path grant/);
61
+ bao.seen.length = 0;
62
+ await assert.rejects(stack.deploy(one('a', '/p/x')), /Bao\.Policy: b → a would land/);
63
+ assert.deepEqual(writesOf(bao.seen), []);
64
+ assert.ok(store.live.has('sys/policies/acl/a'));
65
+ // ★ Finishing the move instead replaces cleanly and deletes the old name.
66
+ assert.deepEqual(await stack.deploy(one('b', '/p/x')), { X: 'replace' });
67
+ assert.deepEqual([...store.live.keys()], ['sys/policies/acl/b']);
68
+ });
69
+ });
70
+ });
71
+
72
+ /** Every group resolves, to an id named after it. */
73
+ const groups = Layer.succeed(CloudflarePermissionGroups, {
74
+ resolve: (_mount, refs) =>
75
+ Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
76
+ });
77
+ const roleProviders = Layer.mergeAll(
78
+ BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
79
+ BaoProxmoxRoleProvider(),
80
+ FetchHttpClient.layer,
81
+ );
82
+
83
+ const cf = (id: string, name: string) =>
84
+ BaoCloudflareRole(id, {
85
+ description: 'Read DNS records on one zone. (zone: example.com)',
86
+ maxTtl: '1h',
87
+ mount: 'cloudflare-example',
88
+ name,
89
+ policies: [
90
+ {
91
+ effect: 'allow',
92
+ groupOrder: 'declared',
93
+ groups: ['DNS Read'],
94
+ resource: 'com.cloudflare.api.account.zone.z1',
95
+ },
96
+ ],
97
+ ttl: '5m',
98
+ }).pipe(RemovalPolicy.destroy());
99
+
100
+ describe('the role families: a move onto a role that exists fails the plan', () => {
101
+ it('Bao.CloudflareRole: a swap under destroy writes nothing', async () => {
102
+ const store = cloudflareRoles();
103
+ const body = (x: string, y: string): StackBody =>
104
+ Effect.gen(function* () {
105
+ yield* cf('X', x);
106
+ yield* cf('Y', y);
107
+ });
108
+ await withFakeStack(roleProviders, store, async (stack, bao) => {
109
+ await stack.deploy(body('dns-read', 'dns-edit'));
110
+ bao.seen.length = 0;
111
+ await assert.rejects(stack.deploy(body('dns-edit', 'dns-read')), OCCUPIED);
112
+ assert.deepEqual(writesOf(bao.seen), []);
113
+ assert.equal(store.live.size, 2);
114
+ });
115
+ });
116
+
117
+ it('Bao.ProxmoxRole: a rename onto a role made by hand writes nothing', async () => {
118
+ const store = proxmoxRoles();
119
+ const role = (name: string): StackBody =>
120
+ Effect.asVoid(
121
+ BaoProxmoxRole('Role', {
122
+ maxTtl: '6h',
123
+ mintUser: 'reader@pve',
124
+ mount: 'proxmox-lab',
125
+ name,
126
+ ttl: '1h',
127
+ }),
128
+ );
129
+ await withFakeStack(roleProviders, store, async (stack, bao) => {
130
+ await stack.deploy(role('read'));
131
+ store.live.set('proxmox-lab/roles/audit', { max_ttl: 0, mint_user: 'auditor@pve', ttl: 0 });
132
+ bao.seen.length = 0;
133
+ await assert.rejects(stack.deploy(role('audit')), OCCUPIED);
134
+ assert.deepEqual(writesOf(bao.seen), []);
135
+ assert.equal(store.live.get('proxmox-lab/roles/audit')?.['mint_user'], 'auditor@pve');
136
+ });
137
+ });
138
+ });
139
+
140
+ describe('Bao.ProxmoxRole: a re-scoping rename whose allowance is still an Output', () => {
141
+ it('defers instead of refusing a value it cannot read yet, then checks it once resolved', async () => {
142
+ const store = proxmoxRoles();
143
+ const base = { maxTtl: '6h', mount: 'proxmox-lab', ttl: '1h' } as const;
144
+ const body = (ttl: string, name: string, mintUser: string): StackBody =>
145
+ Effect.gen(function* () {
146
+ const up = yield* BaoProxmoxRole('Upstream', {
147
+ ...base,
148
+ mintUser: 'up@pve',
149
+ name: 'up',
150
+ ttl,
151
+ });
152
+ const allowMintUserChange = Output.interpolate`reader-${up.ttl}@pve`;
153
+ yield* BaoProxmoxRole('Role', { ...base, allowMintUserChange, mintUser, name });
154
+ });
155
+ await withFakeStack(roleProviders, store, async (stack) => {
156
+ await stack.deploy(body('1h', 'read', 'reader@pve'));
157
+ // ★ Before the defer, this died at plan asking for the allowance it had been given.
158
+ await assert.rejects(stack.deploy(body('2h', 'reader', 'writer@pve')), /identity moved/);
159
+ await assert.rejects(
160
+ stack.deploy(body('2h', 'reader', 'writer@pve')),
161
+ /the rename re-scopes mint_user reader@pve/,
162
+ );
163
+ assert.ok(!store.live.has('proxmox-lab/roles/reader'));
164
+ });
165
+ });
166
+ });
@@ -0,0 +1,162 @@
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.
4
+ *
5
+ * ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
6
+ * (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
7
+ * `RemovalPolicy.destroy()`, planned `update` and left BOTH policies live. diff read the NEW
8
+ * path, found nothing, answered `update`, and reconcile wrote the new object. The old one stayed
9
+ * live under no state record, so no later plan would mention it, and not even `destroy` could
10
+ * remove it.
11
+ * ★ A CHANGED IDENTITY IS A DIFFERENT OBJECT, SO THE ANSWER IS `replace`. Create-first
12
+ * (`deleteFirst` false): the new path never collides with the old, the new generation is written,
13
+ * dependents are updated in the same graph, and only then is the old generation deleted. The
14
+ * engine hands that delete the OLD generation's attributes (Apply.ts deleteOldGenerations and the
15
+ * GC pass both pass `output: old.attr`), and every delete here reads `output`, never `news`.
16
+ * ⛔ A new path that collides with ANOTHER live object is refused at plan instead (`judgeMove`).
17
+ * ⚠️ UNDER `retain` THE OLD OBJECT STAYS LIVE. Apply.ts:2164-2173 skips the old generation's delete
18
+ * and logs "Retaining replaced resource (removal policy: retain)". The plan still says `replace`
19
+ * and the apply says what it kept, so it is no longer silent, but the old object is unmanaged
20
+ * from then on. Remove it by hand, or opt into `RemovalPolicy.destroy()`.
21
+ */
22
+ import * as Effect from 'effect/Effect';
23
+ import { baoRead } from './bao-http.ts';
24
+
25
+ const exact = (identity: string): string => identity;
26
+
27
+ /**
28
+ * A string prop of a diff's `news`, once that one prop is resolved; `undefined` while it is still an
29
+ * Output of a changing upstream (a function-typed proxy) or an Effect.
30
+ *
31
+ * ★ THE IDENTITY IS JUDGED BEFORE `isResolved(news)`. That guard returns `undefined` when ANY prop is
32
+ * pending, and the engine then falls back to `update` (Plan.ts, `havePropsChanged`). A rename that
33
+ * lands in the same deploy as a pending `fragments` or `policies` Output would slip through as
34
+ * `update`, which is the orphan again. launchd/host-effect.ts reads its identity the same way.
35
+ */
36
+ export const declaredString = (news: unknown, key: string): string | undefined => {
37
+ if (typeof news !== 'object' || news === null) return undefined;
38
+ const value: unknown = (news as Record<string, unknown>)[key];
39
+ return typeof value === 'string' ? value : undefined;
40
+ };
41
+
42
+ /** True while an optional prop is declared but is not yet a value: an Output of a changing upstream. */
43
+ export const isPendingProp = (news: unknown, key: string): boolean => {
44
+ if (typeof news !== 'object' || news === null) return false;
45
+ const value: unknown = (news as Record<string, unknown>)[key];
46
+ return value !== undefined && typeof value !== 'string';
47
+ };
48
+
49
+ /** A role's API path from a diff's `news`, once both `mount` and `name` are resolved. */
50
+ export const declaredRolePath = (
51
+ news: unknown,
52
+ rolePath: (mount: string, name: string) => string,
53
+ ): string | undefined => {
54
+ const mount = declaredString(news, 'mount');
55
+ const name = declaredString(news, 'name');
56
+ return mount === undefined || name === undefined ? undefined : rolePath(mount, name);
57
+ };
58
+
59
+ /**
60
+ * How OpenBao keys an ACL policy: trimmed and lowercased. openbao v2.6.2
61
+ * vault/policy/policy_store.go:698-700 (`sanitizeName`), applied by SetPolicy (:268), GetPolicy
62
+ * (:378) and DeletePolicy (:562).
63
+ *
64
+ * ⛔ SO `Admin` → `admin` IS THE SAME POLICY, NOT A RENAME. A `replace` there would write it, and then,
65
+ * under `destroy`, delete the old generation, which is that same policy. Every grant it carries
66
+ * would be revoked by a change of case.
67
+ */
68
+ export const policyKey = (name: string): string => name.trim().toLowerCase();
69
+
70
+ /**
71
+ * Whether the declared identity names a different object than the one in state: `true` → moved (the
72
+ * diff asks `judgeMove`, which also reads the target); `false` → the same object; `undefined` → not
73
+ * knowable yet, because the declared side is still an Output. `key` is how the SERVER keys the object (`policyKey`); the default is exact,
74
+ * which is right for a mount path (vault/routing/router.go folds no case) and for both plugins'
75
+ * roles (each stores `roles/<name>` verbatim).
76
+ *
77
+ * ⛔ NEVER `replace` ON AN UNKNOWN IDENTITY. The Output may resolve to the same path, and a replace
78
+ * onto the same path deletes, under `destroy`, the object it has just written. The diff defers
79
+ * instead. If the engine then plans `update` and the identity turns out to have changed,
80
+ * `refuseMovedUpdate` stops reconcile before it writes anything.
81
+ */
82
+ export const isMoved = (
83
+ stored: string,
84
+ declared: string | undefined,
85
+ key: (identity: string) => string = exact,
86
+ ): boolean | undefined => (declared === undefined ? undefined : key(stored) !== key(declared));
87
+
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
+ /**
100
+ * The diff's first question, asked before `isResolved(news)`: the move from the identity this
101
+ * resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
102
+ * object or either is still unknown. `pathOf` is the API path an identity is read at.
103
+ *
104
+ * ⛔ A MOVE ONTO AN OBJECT THAT ALREADY EXISTS FAILS THE PLAN. MEASURED 2026-09-21 through the engine
105
+ * (rename-occupied.test.ts): two policies that swapped names under `RemovalPolicy.destroy()` each
106
+ * planned `replace`, each new generation wrote over the other's live policy, and each old
107
+ * generation's delete then removed the name the other had just written. The deploy was green and
108
+ * both policies were gone, every grant revoked. A shift (a → b while b → c), and a move onto the
109
+ * name of a resource leaving the stack, were measured ending the same way. So was reverting a
110
+ * move whose new generation failed: the old generation still holds the name, and its delete runs
111
+ * after the revert has rewritten it. That is why `tried` falls back to the props of an unfinished
112
+ * write.
113
+ * ⚠️ THE DIFF CANNOT SEE THE REMOVAL POLICY, SO THIS REFUSES UNDER `retain` TOO, where a swap, or a
114
+ * move back onto a retained old generation, would have been harmless. The cost is a move in two
115
+ * deploys through a free name, or removing the target by hand. The alternative is a green deploy
116
+ * that deletes what it has just written.
117
+ * ★ ONE PLAN-TIME READ, OF A PATH THE NEW GENERATION'S RECONCILE READS ANYWAY. A 404, a missing mount
118
+ * included, is free; a refused read fails the plan, as any other diff read does.
119
+ */
120
+ export const judgeMove = (
121
+ family: string,
122
+ tried: string | undefined,
123
+ declared: string | undefined,
124
+ pathOf: (identity: string) => string,
125
+ key: (identity: string) => string = exact,
126
+ ) =>
127
+ Effect.gen(function* () {
128
+ if (tried === undefined || declared === undefined || key(tried) === key(declared)) {
129
+ return undefined;
130
+ }
131
+ if ((yield* baoRead(pathOf(declared))) === undefined) return { from: tried, to: declared };
132
+ return yield* Effect.die(
133
+ new Error(
134
+ `${family}: ${tried} → ${declared} would land on an object that already exists at ` +
135
+ `${declared}. Under RemovalPolicy.destroy() a resource moving off that name (a swap, a ` +
136
+ 'shift, or a reverted move that did not finish) deletes it after this one writes it. ' +
137
+ 'Nothing was written. Move through a name nothing holds, in two deploys, or remove ' +
138
+ `${declared} by hand first.`,
139
+ ),
140
+ );
141
+ });
142
+
143
+ /**
144
+ * Reconcile's guard: an `update` whose prior attributes name another object.
145
+ *
146
+ * ★ REACHED ONLY WHEN THE DIFF COULD NOT SEE THE NEW IDENTITY at plan time. A fresh create, and the
147
+ * new generation of a `replace`, both reach reconcile with `output` undefined (Apply.ts passes
148
+ * `output: attr`, which is empty for a new generation). An `update` reaches it with the previous
149
+ * generation's attributes.
150
+ * ★ THE FAILED DEPLOY SETTLES ITSELF. Apply has already committed the row as `updating`, with the new
151
+ * props and the OLD attributes. By the next plan the upstream Output has landed, so the diff sees
152
+ * both names and answers `replace` (or refuses, if the new name is already taken: `judgeMove`).
153
+ * The old object is then deleted, or retained, by the removal policy, with no hand repair.
154
+ */
155
+ export const refuseMovedUpdate = (family: string, from: string, to: string) =>
156
+ Effect.die(
157
+ new Error(
158
+ `${family}: planned as an update, but its identity moved from ${from} to ${to}. Writing ` +
159
+ `${to} now would leave ${from} live with no state record. Nothing was written. Run the ` +
160
+ 'deploy again: once the value resolves at plan time it plans as a replace.',
161
+ ),
162
+ );
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The shared role loop against a fake, through Bao.JwtAuthConfig — the family where `wouldErase`
3
+ * matters most: a live OIDC client must never be written over, because the write would erase a
4
+ * secret the read never shows.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
9
+ import { jwtConfigSpec } from './jwt-config.ts';
10
+ import { problems } from './jwt-config-form.ts';
11
+ import { planRole, reconcileRole } from './role-reconcile.ts';
12
+
13
+ const PROPS = {
14
+ boundIssuer: 'https://token.actions.example.com',
15
+ mount: 'jwt-github',
16
+ oidcDiscoveryUrl: 'https://token.actions.example.com',
17
+ };
18
+
19
+ const STORED = {
20
+ bound_issuer: 'https://token.actions.example.com',
21
+ default_role: '',
22
+ jwks_ca_pem: '',
23
+ jwks_url: '',
24
+ jwt_supported_algs: [],
25
+ jwt_validation_pubkeys: [],
26
+ namespace_in_state: true,
27
+ oidc_client_id: '',
28
+ oidc_discovery_ca_pem: '',
29
+ oidc_discovery_url: 'https://token.actions.example.com',
30
+ oidc_response_mode: '',
31
+ oidc_response_types: [],
32
+ override_allowed_server_names: null,
33
+ provider_config: {},
34
+ status: 'valid',
35
+ };
36
+
37
+ /** Reads answer from `reads` in order (the last repeats); writes answer 204. */
38
+ const script = (...reads: Reply[]) => {
39
+ let count = 0;
40
+ return (seen: Seen): Reply => {
41
+ if (seen.method !== 'GET') return { status: 204 };
42
+ const reply = reads[Math.min(count, reads.length - 1)] ?? { status: 500 };
43
+ count += 1;
44
+ return reply;
45
+ };
46
+ };
47
+ const ABSENT: Reply = { json: { errors: [] }, status: 404 };
48
+ const found = (data: Record<string, unknown>): Reply => ({ json: { data }, status: 200 });
49
+ const writes = (seen: Seen[]) => seen.filter((each) => each.method !== 'GET');
50
+
51
+ describe('role loop (Bao.JwtAuthConfig)', () => {
52
+ it('writes an absent config once, as PUT, and returns what it read back', async () => {
53
+ await withFake(script(ABSENT, found(STORED)), async (bao) => {
54
+ const attributes = await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
55
+ assert.equal(attributes.mount, 'jwt-github');
56
+ const [write] = writes(bao.seen);
57
+ assert.equal(write?.method, 'PUT');
58
+ assert.equal(write?.path, '/v1/auth/jwt-github/config');
59
+ assert.ok(!(write?.body ?? '').includes('oidc_client_secret'));
60
+ });
61
+ });
62
+
63
+ it('writes nothing and plans noop when live already matches', async () => {
64
+ await withFake(script(found(STORED)), async (bao) => {
65
+ assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(PROPS))), 'noop');
66
+ await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
67
+ assert.equal(writes(bao.seen).length, 0);
68
+ });
69
+ });
70
+
71
+ it('refuses to write over a live OIDC client, naming what would be erased', async () => {
72
+ const oidc = found({
73
+ ...STORED,
74
+ bound_issuer: 'https://old.example.com',
75
+ oidc_client_id: 'client-a',
76
+ });
77
+ await withFake(script(oidc), async (bao) => {
78
+ await assert.rejects(
79
+ run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
80
+ /oidc_client_id \(and its unreadable secret\)/,
81
+ );
82
+ assert.equal(writes(bao.seen).length, 0);
83
+ });
84
+ });
85
+
86
+ it('refuses a write that reads back different', async () => {
87
+ const other = found({ ...STORED, bound_issuer: 'https://elsewhere.example.com' });
88
+ await withFake(script(ABSENT, other), async (bao) => {
89
+ await assert.rejects(
90
+ run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
91
+ /read back different/,
92
+ );
93
+ });
94
+ });
95
+
96
+ it('plans update, never noop, for a declaration it would refuse', async () => {
97
+ const bad = { ...PROPS, jwksUrl: 'https://keys.example.com' };
98
+ assert.match(problems(bad).join(), /exactly one/);
99
+ await withFake(script(found(STORED)), async (bao) => {
100
+ assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(bad))), 'update');
101
+ assert.equal(bao.seen.length, 0);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The read / plan / reconcile loop the newer role families share (Bao.JwtRole, Bao.KubernetesRole,
3
+ * Bao.JwtAuthConfig): one path, one body, one comparison. Each family supplies its own form; this
4
+ * owns the order of operations, which is where the estate's providers have gone wrong before.
5
+ *
6
+ * ★ THE ORDER, AND WHY EACH STEP IS THERE:
7
+ * 1. Refuse a declaration the family knows is wrong, before any call (`problems`).
8
+ * 2. Read live. Write only when it differs — an identical write is still an audit line.
9
+ * 3. Before writing over a live object, refuse if the write would silently destroy something
10
+ * this resource does not model (`wouldErase`, the ssh-role.ts pattern).
11
+ * 4. Re-read, and refuse unless live now MATCHES. A 2xx is not proof: the server can accept a
12
+ * field and store something else (pki-role.ts has the history).
13
+ * ⛔ A DECLARATION reconcile WOULD REFUSE NEVER PLANS `noop` — `planRole` routes it to `update`,
14
+ * so the refusal is seen at the next deploy instead of hiding behind a clean plan.
15
+ */
16
+ import * as Effect from 'effect/Effect';
17
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import { baoRead, baoWrite } from './bao-http.ts';
19
+ import type { BaoError } from './bao-status.ts';
20
+
21
+ export interface RoleSpec<A> {
22
+ /** `Bao.JwtRole` etc., for messages. */
23
+ readonly family: string;
24
+ /** The API path, e.g. `auth/jwt/role/humans`. */
25
+ readonly path: string;
26
+ readonly problems: readonly string[];
27
+ readonly attributesOf: (live: Record<string, unknown>) => A;
28
+ readonly matches: (attributes: A) => boolean;
29
+ readonly body: Readonly<Record<string, unknown>>;
30
+ /** Live fields a write would reset that the declaration does not model. */
31
+ readonly wouldErase?: (live: Record<string, unknown>) => readonly string[];
32
+ }
33
+
34
+ type Found<A> = { readonly attributes: A; readonly live: Record<string, unknown> };
35
+
36
+ export const readRoleAt = <A>(
37
+ spec: RoleSpec<A>,
38
+ ): Effect.Effect<Found<A> | undefined, BaoError, HttpClient.HttpClient> =>
39
+ Effect.map(baoRead(spec.path), (live) =>
40
+ live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
41
+ );
42
+
43
+ /** `update` or `noop` — the part of a diff after the identity checks. */
44
+ export const planRole = <A>(
45
+ spec: RoleSpec<A>,
46
+ ): Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient> =>
47
+ Effect.gen(function* () {
48
+ if (spec.problems.length > 0) return 'update';
49
+ const found = yield* readRoleAt(spec);
50
+ return found !== undefined && spec.matches(found.attributes) ? 'noop' : 'update';
51
+ });
52
+
53
+ const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
54
+ Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
55
+
56
+ export const reconcileRole = <A>(
57
+ spec: RoleSpec<A>,
58
+ ): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
59
+ Effect.gen(function* () {
60
+ if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
61
+ const found = yield* readRoleAt(spec);
62
+ if (found === undefined || !spec.matches(found.attributes)) {
63
+ const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
64
+ if (lost.length > 0) {
65
+ return yield* refuse(
66
+ spec,
67
+ `live carries ${lost.join(', ')}, which this resource does not declare and the write ` +
68
+ 'would reset. Fold it into the declaration, or clear it by hand first.',
69
+ );
70
+ }
71
+ // ★ PUT, as `bao write` sends (api/logical.go); http/logical.go maps PUT and POST alike.
72
+ yield* baoWrite('PUT', spec.path, spec.body);
73
+ }
74
+ const after = yield* readRoleAt(spec);
75
+ if (after === undefined) return yield* refuse(spec, 'write reported success but it is absent.');
76
+ if (!spec.matches(after.attributes)) {
77
+ return yield* refuse(
78
+ spec,
79
+ 'wrote cleanly but read back different — OpenBao stored something other than what was ' +
80
+ 'declared. Read it by hand before retrying.',
81
+ );
82
+ }
83
+ return after.attributes;
84
+ });