@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,164 @@
1
+ /**
2
+ * A changed mount path: refused without `remountFrom`, moved with it — for secrets engines and for
3
+ * auth methods. The fake keeps a live mount table, so the move is judged by what it leaves behind.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import { reconcileAuthMethod } from './auth-method-reconcile.ts';
8
+ import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
9
+ import { planMove } from './mount-move.ts';
10
+ import { reconcileMount } from './mount-reconcile.ts';
11
+
12
+ describe('planMove', () => {
13
+ it('stays when the path is unchanged, whatever remountFrom says', () => {
14
+ assert.deepEqual(planMove('Bao.Mount', 'kv', 'kv/', undefined), { kind: 'stay' });
15
+ assert.deepEqual(planMove('Bao.Mount', 'kv', 'kv', 'old'), { kind: 'stay' });
16
+ assert.deepEqual(planMove('Bao.Mount', undefined, 'kv', undefined), { kind: 'stay' });
17
+ });
18
+
19
+ it('refuses a changed path with no remountFrom, and names the fix', () => {
20
+ const move = planMove('Bao.Mount', 'kv', 'kv2', undefined);
21
+ assert.equal(move.kind, 'refuse');
22
+ assert.match(move.kind === 'refuse' ? move.message : '', /EMPTY mount.*remountFrom: 'kv'/s);
23
+ });
24
+
25
+ it('refuses a remountFrom that is not the path in state', () => {
26
+ assert.equal(planMove('Bao.Mount', 'kv', 'kv2', 'other').kind, 'refuse');
27
+ });
28
+
29
+ it('moves when remountFrom names the stated path, or on a first reconcile', () => {
30
+ assert.deepEqual(planMove('Bao.Mount', 'kv/', 'kv2', 'kv/'), { from: 'kv', kind: 'move' });
31
+ assert.deepEqual(planMove('Bao.Mount', undefined, 'kv2', 'kv'), { from: 'kv', kind: 'move' });
32
+ });
33
+ });
34
+
35
+ /** A fake with a live table under `prefix` (`sys/mounts` or `sys/auth`) that sys/remount edits. */
36
+ const liveTable = (prefix: string, initial: Record<string, string>) => {
37
+ const table = new Map(Object.entries(initial).map(([path, type]) => [`${path}/`, type]));
38
+ const entry = (type: string) => ({ config: { default_lease_ttl: 0, max_lease_ttl: 0 }, type });
39
+ const answer = (seen: Seen): Reply => {
40
+ const path = seen.path.replace(/^\/v1\//, '');
41
+ if (path === 'sys/remount' && seen.method === 'POST') {
42
+ const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
43
+ const key = `${from.replace(/^auth\//, '')}/`;
44
+ const type = table.get(key);
45
+ if (type === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
46
+ table.delete(key);
47
+ table.set(`${to.replace(/^auth\//, '')}/`, type);
48
+ return { json: { data: { migration_id: 'm-1' } }, status: 200 };
49
+ }
50
+ if (path.startsWith('sys/remount/status/')) {
51
+ return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
52
+ }
53
+ if (path === prefix) {
54
+ return { json: { data: Object.fromEntries(table) }, status: 200 };
55
+ }
56
+ const mount = path.slice(prefix.length + 1).replace(/\/tune$/, '');
57
+ if (seen.method === 'POST') {
58
+ if (!path.endsWith('/tune')) table.set(`${mount}/`, 'enabled-by-test');
59
+ return { status: 204 };
60
+ }
61
+ const type = table.get(`${mount}/`);
62
+ return type === undefined
63
+ ? { json: { errors: [`No secret engine mount at ${mount}/`] }, status: 400 }
64
+ : { json: { data: entry(type) }, status: 200 };
65
+ };
66
+ return { answer, table };
67
+ };
68
+
69
+ const posts = (seen: Seen[]) =>
70
+ seen.filter((each) => each.method === 'POST').map((each) => each.path);
71
+
72
+ describe('reconcileMount', () => {
73
+ it('moves kv to its new path with sys/remount, and enables nothing', async () => {
74
+ const live = liveTable('sys/mounts', { kv: 'kv' });
75
+ await withFake(live.answer, async (bao) => {
76
+ const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
77
+ const attributes = await run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv'));
78
+ assert.equal(attributes.path, 'kv-moved');
79
+ assert.deepEqual(posts(bao.seen), ['/v1/sys/remount']);
80
+ assert.deepEqual([...live.table.keys()], ['kv-moved/']);
81
+ });
82
+ });
83
+
84
+ it('refuses a changed path with no remountFrom before any write', async () => {
85
+ const live = liveTable('sys/mounts', { kv: 'kv' });
86
+ await withFake(live.answer, async (bao) => {
87
+ await assert.rejects(
88
+ run({ BAO_ADDR: bao.address }, reconcileMount({ path: 'kv-moved', type: 'kv' }, 'kv')),
89
+ /EMPTY mount/,
90
+ );
91
+ assert.deepEqual(posts(bao.seen), []);
92
+ });
93
+ });
94
+
95
+ it('converges when the move already happened (an interrupted apply re-running)', async () => {
96
+ const live = liveTable('sys/mounts', { 'kv-moved': 'kv' });
97
+ await withFake(live.answer, async (bao) => {
98
+ const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
99
+ await run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv'));
100
+ assert.deepEqual(posts(bao.seen), []);
101
+ });
102
+ });
103
+
104
+ it('refuses when neither path exists, rather than enabling an empty mount', async () => {
105
+ const live = liveTable('sys/mounts', {});
106
+ await withFake(live.answer, async (bao) => {
107
+ const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
108
+ await assert.rejects(
109
+ run({ BAO_ADDR: bao.address }, reconcileMount(props, undefined)),
110
+ /empty mount where data was expected/,
111
+ );
112
+ assert.deepEqual(posts(bao.seen), []);
113
+ });
114
+ });
115
+
116
+ it('refuses when both paths exist, or the source is another type', async () => {
117
+ const both = liveTable('sys/mounts', { kv: 'kv', 'kv-moved': 'kv' });
118
+ await withFake(both.answer, async (bao) => {
119
+ const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
120
+ await assert.rejects(run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv')), /both/);
121
+ });
122
+ const typed = liveTable('sys/mounts', { kv: 'pki' });
123
+ await withFake(typed.answer, async (bao) => {
124
+ const props = { path: 'kv-moved', remountFrom: 'kv', type: 'kv' };
125
+ await assert.rejects(run({ BAO_ADDR: bao.address }, reconcileMount(props, 'kv')), /type/);
126
+ });
127
+ });
128
+ });
129
+
130
+ describe('a type change (REPLACE.md)', () => {
131
+ it("fails the replacement's create on the occupied path, before any write", async () => {
132
+ const live = liveTable('sys/mounts', { kv: 'kv' });
133
+ await withFake(live.answer, async (bao) => {
134
+ // ★ A replacement's new generation reconciles with no state: `stated` is undefined.
135
+ const replacement = reconcileMount({ path: 'kv', type: 'pki' }, undefined);
136
+ await assert.rejects(run({ BAO_ADDR: bao.address }, replacement), /immutable/);
137
+ assert.deepEqual(posts(bao.seen), []);
138
+ assert.deepEqual([...live.table.entries()], [['kv/', 'kv']]);
139
+ });
140
+ });
141
+ });
142
+
143
+ describe('reconcileAuthMethod', () => {
144
+ it('moves an auth method through sys/remount with the auth/ prefix', async () => {
145
+ const live = liveTable('sys/auth', { jwt: 'jwt' });
146
+ await withFake(live.answer, async (bao) => {
147
+ const props = { path: 'jwt-humans', remountFrom: 'jwt', type: 'jwt' };
148
+ await run({ BAO_ADDR: bao.address }, reconcileAuthMethod(props, 'jwt'));
149
+ const start = bao.seen.find((seen) => seen.path === '/v1/sys/remount');
150
+ assert.equal(start?.body, JSON.stringify({ from: 'auth/jwt', to: 'auth/jwt-humans' }));
151
+ assert.deepEqual([...live.table.keys()], ['jwt-humans/']);
152
+ });
153
+ });
154
+
155
+ it('refuses a changed auth path with no remountFrom', async () => {
156
+ const live = liveTable('sys/auth', { jwt: 'jwt' });
157
+ await withFake(live.answer, async (bao) => {
158
+ await assert.rejects(
159
+ run({ BAO_ADDR: bao.address }, reconcileAuthMethod({ path: 'jwt2', type: 'jwt' }, 'jwt')),
160
+ /Bao\.AuthMethod: path changed/,
161
+ );
162
+ });
163
+ });
164
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What a changed path means for Bao.Mount and Bao.AuthMethod — and the move, when one is declared.
3
+ *
4
+ * ⛔ A CHANGED PATH USED TO PLAN `update` AND ENABLE AN EMPTY MOUNT AT THE NEW PATH. diff read the
5
+ * new path, found nothing, answered `update`, and reconcile ran `enable` there: a fresh, empty
6
+ * `kv2/` beside the retained `kv/` that still held every secret, with Alchemy's state now naming
7
+ * only the empty one. Nothing failed, so nothing said so (site-config plan, Guards).
8
+ * ★ NOW A CHANGED PATH FAILS AT PLAN TIME, unless the declaration says where it came from:
9
+ * `remountFrom: '<old path>'` moves the mount with `sys/remount` (remount-wire.ts), which keeps
10
+ * every secret. A genuinely new, empty mount is a new resource id, never an edited path.
11
+ * ★ THE MOVE IS DECIDED BY LIVE STATE, SO A RE-RUN CONVERGES. Source present and target absent is
12
+ * a move; target present and source absent is a move that already happened; both present, or
13
+ * neither, is a refusal — "neither" in particular is exactly the empty-mount failure above.
14
+ */
15
+ import * as Effect from 'effect/Effect';
16
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
17
+ import type { BaoError } from './bao-status.ts';
18
+ import { mountPath } from './mount-form.ts';
19
+ import { type RemountError, type RemountOptions, remountAndWait } from './remount-wire.ts';
20
+
21
+ export type Move =
22
+ | { readonly kind: 'stay' }
23
+ | { readonly kind: 'move'; readonly from: string }
24
+ | { readonly kind: 'refuse'; readonly message: string };
25
+
26
+ /**
27
+ * @param family `Bao.Mount` or `Bao.AuthMethod`, for the message.
28
+ * @param stated the path Alchemy's state holds, or undefined on a first reconcile.
29
+ * @param path the declared path.
30
+ * @param remountFrom the declared source, if any.
31
+ */
32
+ export const planMove = (
33
+ family: string,
34
+ stated: string | undefined,
35
+ path: string,
36
+ remountFrom: string | undefined,
37
+ ): Move => {
38
+ const to = mountPath(path);
39
+ const from = remountFrom === undefined ? undefined : mountPath(remountFrom);
40
+ if (stated === undefined) {
41
+ return from === undefined || from === to ? { kind: 'stay' } : { kind: 'move', from };
42
+ }
43
+ const was = mountPath(stated);
44
+ if (was === to) return { kind: 'stay' };
45
+ if (from === undefined) {
46
+ return {
47
+ kind: 'refuse',
48
+ message:
49
+ `${family}: path changed from ${was} to ${to}. That would enable an EMPTY mount at ${to} ` +
50
+ `and strand everything under ${was}. To move it with its data, declare ` +
51
+ `\`remountFrom: '${was}'\` (leases under it are revoked; policies are not rewritten). ` +
52
+ 'For a new, empty mount, give it a new resource id instead.',
53
+ };
54
+ }
55
+ if (from !== was) {
56
+ return {
57
+ kind: 'refuse',
58
+ message: `${family}: remountFrom is ${from}, but the mount in state is ${was}.`,
59
+ };
60
+ }
61
+ return { kind: 'move', from };
62
+ };
63
+
64
+ export interface MoveTarget {
65
+ readonly family: string;
66
+ /** Declared path, bare. */
67
+ readonly path: string;
68
+ readonly type: string;
69
+ /** Read one path's live entry — undefined when absent. */
70
+ readonly read: (
71
+ path: string,
72
+ ) => Effect.Effect<{ readonly type: string } | undefined, BaoError, HttpClient.HttpClient>;
73
+ /** The path as sys/remount takes it: the bare path for secrets, `auth/<path>` for auth. */
74
+ readonly wire: (path: string) => string;
75
+ }
76
+
77
+ const refuse = (message: string) => Effect.die(new Error(message));
78
+
79
+ /** Perform a planned move, deciding by live state. Returns once the target is in place. */
80
+ export const performMove = (
81
+ target: MoveTarget,
82
+ from: string,
83
+ options?: RemountOptions,
84
+ ): Effect.Effect<void, BaoError | RemountError, HttpClient.HttpClient> =>
85
+ Effect.gen(function* () {
86
+ const to = mountPath(target.path);
87
+ const source = yield* target.read(from);
88
+ const landed = yield* target.read(to);
89
+ if (source !== undefined && landed !== undefined) {
90
+ return yield* refuse(
91
+ `${target.family}: both ${from} and ${to} exist, so remountFrom cannot move one onto the ` +
92
+ 'other. Decide which holds the data, and retire the other by hand.',
93
+ );
94
+ }
95
+ if (source === undefined && landed === undefined) {
96
+ return yield* refuse(
97
+ `${target.family}: remountFrom ${from} names no mount, and ${to} does not exist either. ` +
98
+ 'Refusing to enable an empty mount where data was expected.',
99
+ );
100
+ }
101
+ if (source === undefined) return; // ★ Already moved — an interrupted apply re-running.
102
+ if (source.type !== target.type) {
103
+ return yield* refuse(
104
+ `${target.family}: ${from} is type ${source.type}, not ${target.type}. A remount keeps the ` +
105
+ 'type; fix the declaration first.',
106
+ );
107
+ }
108
+ const confirm = Effect.map(
109
+ Effect.all([target.read(from), target.read(to)]),
110
+ ([gone, there]) => gone === undefined && there !== undefined,
111
+ );
112
+ yield* remountAndWait(target.wire(from), target.wire(to), confirm, options);
113
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import assert from 'node:assert/strict';
5
5
  import { describe, it } from 'node:test';
6
- import { trimSlashes, trimTrailingSlashes } from './mount-path.ts';
6
+ import { trimRuns, trimSlashes, trimTrailingSlashes } from './mount-path.ts';
7
7
 
8
8
  describe('mount-path', () => {
9
9
  it('trims outer slashes and keeps inner ones', () => {
@@ -21,3 +21,13 @@ describe('mount-path', () => {
21
21
  assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
22
22
  });
23
23
  });
24
+
25
+ describe('trimRuns', () => {
26
+ it('trims any single character and stays linear on long runs', () => {
27
+ assert.equal(trimRuns('--a-b--', '-'), 'a-b');
28
+ const run = '-'.repeat(200_000);
29
+ const started = performance.now();
30
+ assert.equal(trimRuns(`${run}x${run}y`, '-'), `x${run}y`);
31
+ assert.ok(performance.now() - started < 1_000, 'trimming took over a second');
32
+ });
33
+ });
@@ -1,25 +1,31 @@
1
1
  /**
2
- * Slash trimming for caller-supplied mount paths.
2
+ * Trimming runs of one character from caller-supplied paths and names.
3
3
  *
4
- * ★ LOOPS, NOT `/^\/+|\/+$/g` OR `/\/+$/`. A trailing `\/+$` backtracks polynomially on a long run
5
- * of `/` followed by anything else (CodeQL js/polynomial-redos), and mount paths are caller input.
6
- * Both loops are linear in the input length.
4
+ * ★ LOOPS, NOT `/^\/+|\/+$/g`, `/\/+$/` OR `/^-+|-+$/g`. A trailing `x+$` backtracks polynomially
5
+ * on a long run of `x` followed by anything else (CodeQL js/polynomial-redos), and mount paths,
6
+ * namespaces and slugs are caller input. Every loop here is linear in the input length.
7
7
  */
8
8
 
9
- /** `text` without trailing `/`. */
10
- export const trimTrailingSlashes = (text: string): string => {
9
+ /** `text` without a trailing run of `char` (one UTF-16 unit). */
10
+ export const trimTrailing = (text: string, char: string): string => {
11
11
  let end = text.length;
12
- while (end > 0 && text[end - 1] === '/') end -= 1;
12
+ while (end > 0 && text[end - 1] === char) end -= 1;
13
13
  return text.slice(0, end);
14
14
  };
15
15
 
16
- /** `text` without leading or trailing `/`. */
17
- export const trimSlashes = (text: string): string => {
16
+ /** `text` without a leading or trailing run of `char` (one UTF-16 unit). */
17
+ export const trimRuns = (text: string, char: string): string => {
18
18
  let start = 0;
19
- while (start < text.length && text[start] === '/') start += 1;
20
- return trimTrailingSlashes(text.slice(start));
19
+ while (start < text.length && text[start] === char) start += 1;
20
+ return trimTrailing(text.slice(start), char);
21
21
  };
22
22
 
23
+ /** `text` without trailing `/`. */
24
+ export const trimTrailingSlashes = (text: string): string => trimTrailing(text, '/');
25
+
26
+ /** `text` without leading or trailing `/`. */
27
+ export const trimSlashes = (text: string): string => trimRuns(text, '/');
28
+
23
29
  /** SSH mount path without a trailing slash; `ssh` when the caller did not say. */
24
30
  export const sshMountOf = (mount: string | undefined): string =>
25
31
  trimTrailingSlashes(mount ?? 'ssh');
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Bao.Mount's read and reconcile as plain effects — split from mount.ts so the move (and its
3
+ * refusals) run against a fake server, not only in a reviewer's head.
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
7
+ import type { BaoError } from './bao-status.ts';
8
+ import {
9
+ type BaoMountAttributes,
10
+ type BaoMountProps,
11
+ attributesOf,
12
+ matches,
13
+ mountPath,
14
+ wantsTune,
15
+ } from './mount-form.ts';
16
+ import { type MoveTarget, performMove, planMove } from './mount-move.ts';
17
+ import { enableMount, readMountData, tuneMount } from './mount-wire.ts';
18
+ import type { RemountError, RemountOptions } from './remount-wire.ts';
19
+
20
+ export const readMount = (
21
+ props: BaoMountProps,
22
+ ): Effect.Effect<BaoMountAttributes | undefined, BaoError, HttpClient.HttpClient> =>
23
+ Effect.gen(function* () {
24
+ const live = yield* readMountData(props.path);
25
+ if (live === undefined) return undefined;
26
+ return attributesOf(props, live);
27
+ });
28
+
29
+ const moveTarget = (props: BaoMountProps): MoveTarget => ({
30
+ family: 'Bao.Mount',
31
+ path: props.path,
32
+ read: (path) => readMount({ ...props, path }),
33
+ type: props.type,
34
+ wire: mountPath,
35
+ });
36
+
37
+ /**
38
+ * Converge one mount. `stated` is the path in Alchemy's state (undefined on a first reconcile).
39
+ * Dies on a refusal; fails with OpenBao's own error on a refused call.
40
+ */
41
+ export const reconcileMount = (
42
+ news: BaoMountProps,
43
+ stated: string | undefined,
44
+ options?: RemountOptions,
45
+ ): Effect.Effect<BaoMountAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
46
+ Effect.gen(function* () {
47
+ const move = planMove('Bao.Mount', stated, news.path, news.remountFrom);
48
+ if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
49
+ if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
50
+
51
+ let live = yield* readMount(news);
52
+ if (live === undefined) {
53
+ yield* enableMount(news);
54
+ if (wantsTune(news)) yield* tuneMount(news);
55
+ } else if (!matches(live, news)) {
56
+ if (live.type !== news.type) {
57
+ return yield* Effect.die(
58
+ new Error(
59
+ `Bao.Mount ${mountPath(news.path)}: live type ${live.type} != ${news.type}. ` +
60
+ 'Secrets engine type is immutable — replace manually.',
61
+ ),
62
+ );
63
+ }
64
+ yield* tuneMount(news);
65
+ }
66
+ live = yield* readMount(news);
67
+ if (live === undefined) {
68
+ return yield* Effect.die(
69
+ new Error(
70
+ `Bao.Mount ${mountPath(news.path)}: write returned no error but the mount is still absent.`,
71
+ ),
72
+ );
73
+ }
74
+ return live;
75
+ });
@@ -7,21 +7,25 @@
7
7
  *
8
8
  * ★ `defaultRemovalPolicy: 'retain'` — disabling a mount destroys every secret under it.
9
9
  * Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
10
+ *
11
+ * ★ REPLACE SEMANTICS (audited 2026-09-21, see src/openbao/REPLACE.md):
12
+ * · `type` changed → plans `replace`, and the APPLY FAILS, destroying nothing: the new
13
+ * generation's path is the old one's, still occupied, so its reconcile dies "type is
14
+ * immutable" before any write (mount-reconcile.ts). Change a type by hand. ⛔ Not `deleteFirst`
15
+ * on purpose — under `destroy` that would disable the mount, every secret with it, on a typo.
16
+ * · `path` changed → FAILS the plan unless `remountFrom` names the old path, which is an
17
+ * in-place `update` that moves the mount and its data (mount-move.ts). It used to be an
18
+ * `update` that enabled an EMPTY mount at the new path.
10
19
  */
11
20
  import { Resource } from 'alchemy';
12
21
  import { isResolved } from 'alchemy/Diff';
13
22
  import * as Provider from 'alchemy/Provider';
14
23
  import * as Effect from 'effect/Effect';
15
24
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
- import {
17
- type BaoMountAttributes,
18
- type BaoMountProps,
19
- attributesOf,
20
- matches,
21
- mountPath,
22
- wantsTune,
23
- } from './mount-form.ts';
24
- import { disableMount, enableMount, readMountData, tuneMount } from './mount-wire.ts';
25
+ import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
26
+ import { planMove } from './mount-move.ts';
27
+ import { readMount, reconcileMount } from './mount-reconcile.ts';
28
+ import { disableMount } from './mount-wire.ts';
25
29
 
26
30
  export type { BaoMountAttributes, BaoMountProps };
27
31
 
@@ -37,13 +41,6 @@ export const BaoMount = Resource<BaoMount>('Bao.Mount', {
37
41
  defaultRemovalPolicy: 'retain',
38
42
  });
39
43
 
40
- const readMount = (props: BaoMountProps) =>
41
- Effect.gen(function* () {
42
- const live = yield* readMountData(props.path);
43
- if (live === undefined) return undefined;
44
- return attributesOf(props, live);
45
- });
46
-
47
44
  export const BaoMountProvider = () =>
48
45
  Provider.effect(
49
46
  BaoMount,
@@ -64,6 +61,13 @@ export const BaoMountProvider = () =>
64
61
 
65
62
  diff: Effect.fn(function* ({ news, output }) {
66
63
  if (output === undefined || !isResolved(news)) return undefined;
64
+ /**
65
+ * ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
66
+ * find nothing and plan `update`, which is how an empty mount used to get enabled.
67
+ */
68
+ const move = planMove('Bao.Mount', output.path, news.path, news.remountFrom);
69
+ if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
70
+ if (move.kind === 'move') return { action: 'update' } as const;
67
71
  const live = yield* readMount(news);
68
72
  if (live === undefined) return { action: 'update' } as const;
69
73
  /**
@@ -77,8 +81,9 @@ export const BaoMountProvider = () =>
77
81
  * it is true the engine logs "Retaining replaced resource (removal policy: retain)"
78
82
  * and skips the delete entirely. Every mount this package declares carries the retain
79
83
  * default (see the ★ on the resource below), so a wrong `type` string cannot disable a
80
- * live mount. What it CAN do is try to enable a second mount at an occupied path,
81
- * which OpenBao refuses loudly — a failed deploy, not a lost shelf.
84
+ * live mount. What it CAN do is fail: the new generation reads the SAME path, finds the
85
+ * old type there and dies before any write (mount-reconcile.ts) — a failed deploy, not a
86
+ * lost shelf (corrected 2026-09-21; this said OpenBao refused a second enable).
82
87
  * ⚠️ THAT SAFETY IS THE DECORATION'S, NOT THIS LINE'S. A caller who opts into
83
88
  * `.pipe(RemovalPolicy.destroy())` gets the destructive replace, which is the correct
84
89
  * meaning of opting in and is worth knowing before you type it on a mount.
@@ -91,32 +96,10 @@ export const BaoMountProvider = () =>
91
96
  /**
92
97
  * ★ A REFUSED ENABLE OR TUNE FAILS THE EFFECT WITH OpenBao's OWN `errors`, naming the
93
98
  * method and path — the job the per-command exit-code checks used to do by hand.
99
+ * The body, and the move, live in mount-reconcile.ts.
94
100
  */
95
- reconcile: Effect.fn(function* ({ news }) {
96
- let live = yield* readMount(news);
97
- if (live === undefined) {
98
- yield* enableMount(news);
99
- if (wantsTune(news)) yield* tuneMount(news);
100
- } else if (!matches(live, news)) {
101
- if (live.type !== news.type) {
102
- return yield* Effect.die(
103
- new Error(
104
- `Bao.Mount ${mountPath(news.path)}: live type ${live.type} != ${news.type}. ` +
105
- 'Secrets engine type is immutable — replace manually.',
106
- ),
107
- );
108
- }
109
- yield* tuneMount(news);
110
- }
111
- live = yield* readMount(news);
112
- if (live === undefined) {
113
- return yield* Effect.die(
114
- new Error(
115
- `Bao.Mount ${mountPath(news.path)}: write returned no error but the mount is still absent.`,
116
- ),
117
- );
118
- }
119
- return live;
101
+ reconcile: Effect.fn(function* ({ news, output }) {
102
+ return yield* reconcileMount(news, output?.path);
120
103
  }),
121
104
 
122
105
  /**