@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,71 @@
1
+ /**
2
+ * Crash recovery survives the ownership rule: a deploy that dies after its write and before its
3
+ * commit leaves a `creating` (or `replacing`) row and a live object, and the next deploy must
4
+ * finish it WITHOUT `--adopt` — the object is ours (ownership/probe.ts, ownership/resume.ts).
5
+ * The crash is injected: the first read after a write answers 500, which is exactly a reconcile
6
+ * whose write landed and whose read-back, and so its commit, never did.
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { describe, it } from 'node:test';
12
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
13
+ import { type Seen, crashAfterWrite } from './fake-bao.ts';
14
+ import type { Estate } from './fake-engines-roles.ts';
15
+ import { FAMILIES, withEstate } from './fake-families.ts';
16
+ import { type FakeStack, writesOf } from './fake-stack.ts';
17
+
18
+ const RESUME_REFUSED = /Cannot resume creating resource 'X'.*--adopt/s;
19
+
20
+ type Run = (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>;
21
+ const withCrashes = (body: (crashNext: () => void) => Run) => {
22
+ const fault = crashAfterWrite();
23
+ return withEstate(body(fault.crashNext), fault.wrap);
24
+ };
25
+
26
+ for (const row of FAMILIES) {
27
+ describe(`${row.family}: an interrupted create`, () => {
28
+ it('is resumed without --adopt when the object is what it wrote; nothing is rewritten', async () => {
29
+ await withCrashes((crashNext) => async (stack, estate, seen) => {
30
+ crashNext();
31
+ await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
32
+ assert.ok(row.has(estate, 'a'));
33
+ seen.length = 0;
34
+ assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'create' });
35
+ assert.deepEqual(writesOf(seen), []);
36
+ assert.deepEqual(await stack.deploy(row.declare('X', 'a', '15m')), { X: 'noop' });
37
+ });
38
+ });
39
+
40
+ it('is refused when the object has since changed, and resumed with --adopt', async () => {
41
+ await withCrashes((crashNext) => async (stack, _estate, _seen, owner) => {
42
+ crashNext();
43
+ await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), /injected/);
44
+ // ★ Someone else rewrites it in between: another stack, taking it over with --adopt.
45
+ await owner.deploy(row.declare('O', 'a', '30m'), { adopt: true });
46
+ await assert.rejects(stack.deploy(row.declare('X', 'a', '15m')), RESUME_REFUSED);
47
+ const resumed = await stack.deploy(row.declare('X', 'a', '15m'), { adopt: true });
48
+ assert.deepEqual(resumed, { X: 'create' });
49
+ });
50
+ });
51
+ });
52
+ }
53
+
54
+ /** Bao.MfaTotpMethod never renames (mfa-totp.ts), so it has no replace to interrupt. */
55
+ for (const row of FAMILIES.filter((each) => each.family !== 'Bao.MfaTotpMethod')) {
56
+ describe(`${row.family}: an interrupted replace`, () => {
57
+ it('is resumed without --adopt: the new generation it wrote is not taken over', async () => {
58
+ await withCrashes((crashNext) => async (stack, estate) => {
59
+ const { retain } = RemovalPolicy;
60
+ await stack.deploy(row.declare('X', 'a', '15m', retain));
61
+ crashNext();
62
+ await assert.rejects(stack.deploy(row.declare('X', 'b', '15m', retain)), /injected/);
63
+ assert.ok(row.has(estate, 'b'));
64
+ assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), {
65
+ X: 'replace',
66
+ });
67
+ assert.deepEqual(await stack.deploy(row.declare('X', 'b', '15m', retain)), { X: 'noop' });
68
+ });
69
+ });
70
+ });
71
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as Effect from 'effect/Effect';
8
8
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
9
+ import type { Claim } from '../ownership/adopt.ts';
9
10
  import {
10
11
  type BaoAuthMethodAttributes,
11
12
  type BaoAuthMethodProps,
@@ -36,22 +37,32 @@ const moveTarget = (props: BaoAuthMethodProps): MoveTarget => ({
36
37
  wire: (path) => `auth/${authPath(path)}`,
37
38
  });
38
39
 
39
- /** Converge one auth method. `stated` is the path in Alchemy's state, if any. */
40
+ /**
41
+ * Converge one auth method. `stated` is the path in Alchemy's state, if any.
42
+ * ⛔ `claim`: the same first-reconcile takeover refusal as reconcileMount's (auth-method.ts passes it).
43
+ */
40
44
  export const reconcileAuthMethod = (
41
45
  news: BaoAuthMethodProps,
42
46
  stated: string | undefined,
43
47
  options?: RemountOptions,
48
+ claim?: Claim,
44
49
  ): Effect.Effect<BaoAuthMethodAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
45
50
  Effect.gen(function* () {
46
51
  const move = planMove('Bao.AuthMethod', stated, news.path, news.remountFrom);
47
52
  if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
53
+ const first = stated === undefined ? claim : undefined;
54
+ if (first !== undefined && move.kind === 'move') {
55
+ const source = yield* readMethod({ ...news, path: move.from });
56
+ if (source !== undefined)
57
+ yield* first(`Bao.AuthMethod ${move.from} (the remountFrom source)`);
58
+ }
48
59
  if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
49
60
 
50
61
  let live = yield* readMethod(news);
51
62
  if (live === undefined) {
52
63
  yield* enableAuthMethod(news);
53
64
  if (wantsTune(news)) yield* tuneAuthMethod(news);
54
- } else if (!matches(live, news)) {
65
+ } else {
55
66
  if (live.type !== news.type) {
56
67
  return yield* Effect.die(
57
68
  new Error(
@@ -60,7 +71,8 @@ export const reconcileAuthMethod = (
60
71
  ),
61
72
  );
62
73
  }
63
- yield* tuneAuthMethod(news);
74
+ if (first !== undefined) yield* first(`Bao.AuthMethod ${authPath(news.path)}`);
75
+ if (!matches(live, news)) yield* tuneAuthMethod(news);
64
76
  }
65
77
  live = yield* readMethod(news);
66
78
  if (live === undefined) {
@@ -19,6 +19,9 @@ import { isResolved } from 'alchemy/Diff';
19
19
  import * as Provider from 'alchemy/Provider';
20
20
  import * as Effect from 'effect/Effect';
21
21
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import { claimFor } from '../ownership/adopt.ts';
23
+ import { ownedRead } from '../ownership/probe.ts';
24
+ import { provingResumes } from '../ownership/resume.ts';
22
25
  import {
23
26
  type BaoAuthMethodAttributes,
24
27
  type BaoAuthMethodProps,
@@ -54,12 +57,17 @@ export const BaoAuthMethodProvider = () =>
54
57
  */
55
58
  list: () => Effect.succeed([]),
56
59
 
57
- read: Effect.fn(function* ({ olds }) {
58
- return yield* readMethod(olds);
60
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
61
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
62
+ const found = yield* readMethod(olds);
63
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
64
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
59
65
  }),
60
66
 
61
67
  diff: Effect.fn(function* ({ news, output }) {
62
- if (output === undefined || !isResolved(news)) return undefined;
68
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
69
+ if (output === undefined) return undefined;
70
+ if (!isResolved(news)) return undefined;
63
71
  /**
64
72
  * ⛔ A CHANGED PATH FAILS HERE unless `remountFrom` names the old one — the same trap
65
73
  * Bao.Mount had: reading the new path finds nothing, plans `update`, and enables an
@@ -81,8 +89,9 @@ export const BaoAuthMethodProvider = () =>
81
89
  }),
82
90
 
83
91
  /** The body, and the move, live in auth-method-reconcile.ts. */
84
- reconcile: Effect.fn(function* ({ news, output }) {
85
- return yield* reconcileAuthMethod(news, output?.path);
92
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
93
+ const claim = claimFor({ fqn, instanceId, output });
94
+ return yield* reconcileAuthMethod(news, output?.path, undefined, claim);
86
95
  }),
87
96
 
88
97
  /**
@@ -94,5 +103,5 @@ export const BaoAuthMethodProvider = () =>
94
103
  return undefined;
95
104
  }),
96
105
  }),
97
- ),
106
+ ).pipe(Effect.map(provingResumes)),
98
107
  );
@@ -5,17 +5,11 @@
5
5
  * sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
6
6
  * TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
7
7
  * read hands back that integer, which attributesOf renders as `90d` — the same role.
8
- * And what the diff calls a rename (a `replace`): only a change the server would not fold away.
8
+ * (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
9
9
  */
10
10
  import assert from 'node:assert/strict';
11
11
  import { describe, it } from 'node:test';
12
- import {
13
- type BaoAuthRoleProps,
14
- attributesOf,
15
- isRename,
16
- matches,
17
- writeBody,
18
- } from './auth-role-form.ts';
12
+ import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
19
13
 
20
14
  const PROPS: BaoAuthRoleProps = {
21
15
  bindSecretId: true,
@@ -47,11 +41,3 @@ describe('auth role secret_id TTL', () => {
47
41
  assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
48
42
  });
49
43
  });
50
-
51
- describe('auth role rename', () => {
52
- it('is a rename only when the lower-cased names differ, as the server stores them', () => {
53
- assert.equal(isRename('pve-node--node-a', 'pve-node--node-b'), true);
54
- assert.equal(isRename('Host-Cert', 'host-cert'), false);
55
- assert.equal(isRename('host-cert', 'host-cert'), false);
56
- });
57
- });
@@ -89,17 +89,6 @@ export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
89
89
  return body;
90
90
  };
91
91
 
92
- /**
93
- * Whether a changed `name` names a DIFFERENT role — the diff's `replace` question.
94
- *
95
- * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
96
- * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key, :1537),
97
- * so `Host` → `host` is the SAME role: a `replace` there would write it, then — under `destroy` —
98
- * delete the old generation, which is that same role.
99
- */
100
- export const isRename = (from: string, to: string): boolean =>
101
- from.toLowerCase() !== to.toLowerCase();
102
-
103
92
  const samePolicies = (want: readonly string[], have: readonly string[]) =>
104
93
  want.length === have.length && want.every((policy, index) => policy === have[index]);
105
94
 
@@ -5,24 +5,28 @@
5
5
  * are credential issuance, not configuration, and are OUT OF SCOPE. This declares the
6
6
  * role shape an approle login inherits — policy names and TTL strings only.
7
7
  *
8
- * ★ `defaultRemovalPolicy: 'retain'` — deleting a role revokes every token bound to it.
9
- * Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
8
+ * ★ `defaultRemovalPolicy: 'retain'` — deleting a role locks out every host that logs in with it:
9
+ * its secret_ids go with it (the ⚠️ on `delete` below). Opt in with
10
+ * `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
10
11
  */
11
12
  import { Resource } from 'alchemy';
12
13
  import { isResolved } from 'alchemy/Diff';
13
14
  import * as Provider from 'alchemy/Provider';
14
15
  import * as Effect from 'effect/Effect';
15
16
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
17
+ import { refuseTakeover } from '../ownership/adopt.ts';
18
+ import { ownedRead } from '../ownership/probe.ts';
19
+ import { provingResumes } from '../ownership/resume.ts';
16
20
  import {
17
21
  type BaoAuthRoleAttributes,
18
22
  type BaoAuthRoleProps,
19
23
  attributesOf,
20
- isRename,
21
24
  matches,
22
25
  readPath,
23
26
  writeBody,
24
27
  } from './auth-role-form.ts';
25
28
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
29
+ import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
26
30
 
27
31
  export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
28
32
 
@@ -38,6 +42,14 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
38
42
  defaultRemovalPolicy: 'retain',
39
43
  });
40
44
 
45
+ /**
46
+ * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
47
+ * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key,
48
+ * :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
49
+ * `destroy` — delete the old generation, which is that same role.
50
+ */
51
+ const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
52
+
41
53
  const readRole = (props: BaoAuthRoleProps) =>
42
54
  Effect.gen(function* () {
43
55
  const live = yield* baoRead(readPath(props.name));
@@ -57,35 +69,47 @@ export const BaoAuthRoleProvider = () =>
57
69
  */
58
70
  list: () => Effect.succeed([]),
59
71
 
60
- read: Effect.fn(function* ({ olds }) {
61
- return yield* readRole(olds);
72
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
73
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
74
+ const found = yield* readRole(olds);
75
+ const ours = Effect.sync(() => found !== undefined && matches(found, olds));
76
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
62
77
  }),
63
78
 
64
79
  /**
65
80
  * ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. A role edited in the OpenBao
66
81
  * UI is exactly the drift the check-* gates exist to catch.
67
82
  */
68
- diff: Effect.fn(function* ({ news, output }) {
69
- if (output === undefined || !isResolved(news)) return undefined;
83
+ diff: Effect.fn(function* ({ news, olds, output }) {
70
84
  /**
71
- * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`.
72
- * Until 2026-09-21 a new `name` read nothing, planned `update` and wrote the new role,
73
- * leaving the old one live under no state record: its secret_ids kept logging in for
74
- * their whole TTL, and no plan would ever mention it again (REPLACE.md).
85
+ * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`,
86
+ * judged before `isResolved(news)` (rename-identity.ts). Until 2026-09-21 a new `name`
87
+ * read nothing, planned `update` and wrote the new role, leaving the old one live
88
+ * under no state record: its secret_ids kept logging in for their whole TTL, and no
89
+ * plan would ever mention it again (REPLACE.md). ⛔ A rename onto a role that exists
90
+ * fails the plan.
75
91
  * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
76
92
  * generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
77
93
  * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
78
94
  */
79
- // ⚠️ Case-insensitively, because the server is — the ⚠️ on isRename in auth-role-form.ts.
80
- if (isRename(output.name, news.name)) return { action: 'replace' } as const;
95
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
96
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
97
+ if (output === undefined) return undefined;
98
+ if (move !== undefined) return { action: 'replace' } as const;
99
+ if (!isResolved(news)) return undefined;
81
100
  const live = yield* readRole(news);
82
101
  if (live === undefined) return { action: 'update' } as const;
83
102
  if (matches(live, news)) return { action: 'noop' } as const;
84
103
  return { action: 'update' } as const;
85
104
  }),
86
105
 
87
- reconcile: Effect.fn(function* ({ news }) {
106
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
107
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
108
+ yield* guardRename(IDENTITY, news, output);
88
109
  const live = yield* readRole(news);
110
+ const path = readPath(news.name);
111
+ if (live !== undefined)
112
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.AuthRole ${path}`);
89
113
  if (live === undefined || !matches(live, news)) {
90
114
  yield* baoWrite('PUT', readPath(news.name), writeBody(news));
91
115
  }
@@ -101,13 +125,20 @@ export const BaoAuthRoleProvider = () =>
101
125
  }),
102
126
 
103
127
  /**
104
- * ⛔ DELETING A ROLE REVOKES EVERY TOKEN BOUND TO IT. Idempotent as Alchemy requires
105
- * — already gone is success — but retain is the default for a reason.
128
+ * ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. openbao v2.6.2 pathRoleDelete
129
+ * (builtin/credential/approle/path_role.go:1858-1902) deletes every secret_id
130
+ * (flushRoleSecrets, validation.go:420-446), the role_id mapping and the role, so no new
131
+ * login succeeds, and it revokes nothing. A token already issued lives to its TTL; only
132
+ * its renewal fails, because pathLoginRenew (path_login.go:414-438) refuses once the role
133
+ * is gone. So this is not a containment action: to contain a leaked token, revoke it or
134
+ * its accessor. (Corrected 2026-09-21: this said the delete revoked every token.)
135
+ * Idempotent as Alchemy requires — already gone is success — but retain is the default,
136
+ * because every host logging in with the role is locked out at the delete.
106
137
  */
107
138
  delete: Effect.fn(function* ({ output }) {
108
139
  yield* baoDelete(readPath(output.name));
109
140
  return undefined;
110
141
  }),
111
142
  }),
112
- ),
143
+ ).pipe(Effect.map(provingResumes)),
113
144
  );
@@ -21,6 +21,9 @@ import { isResolved } from 'alchemy/Diff';
21
21
  import * as Provider from 'alchemy/Provider';
22
22
  import * as Effect from 'effect/Effect';
23
23
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
24
+ import { refuseTakeover } from '../ownership/adopt.ts';
25
+ import { ownedRead } from '../ownership/probe.ts';
26
+ import { provingResumes } from '../ownership/resume.ts';
24
27
  import { groupKey, scopeOfResource } from './cloudflare-group-scope.ts';
25
28
  import { CloudflarePermissionGroups, type GroupRef } from './cloudflare-permission-groups.ts';
26
29
  import { resolvePolicies } from './cloudflare-policy.ts';
@@ -38,13 +41,7 @@ import {
38
41
  readCloudflareRole,
39
42
  writeCloudflareRole,
40
43
  } from './cloudflare-role-wire.ts';
41
- import {
42
- declaredRolePath,
43
- isMoved,
44
- judgeMove,
45
- refuseMovedUpdate,
46
- triedRolePath,
47
- } from './rename.ts';
44
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
48
45
 
49
46
  export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
50
47
 
@@ -66,6 +63,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
66
63
  defaultRemovalPolicy: 'retain',
67
64
  });
68
65
 
66
+ /** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
67
+ const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
68
+
69
69
  export const BaoCloudflareRoleProvider = () =>
70
70
  Provider.effect(
71
71
  BaoCloudflareRole,
@@ -109,9 +109,15 @@ export const BaoCloudflareRoleProvider = () =>
109
109
  */
110
110
  list: () => Effect.succeed([]),
111
111
 
112
- read: Effect.fn(function* ({ olds }) {
112
+ /** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
113
+ read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
113
114
  const live = yield* readCloudflareRole(olds.mount, olds.name);
114
- return live === undefined ? undefined : attributesOf(olds, live);
115
+ const found = live === undefined ? undefined : attributesOf(olds, live);
116
+ const ours = Effect.gen(function* () {
117
+ if (live === undefined || refusalOf(olds) !== undefined) return false;
118
+ return differences(olds, yield* resolved(olds), live).length === 0;
119
+ });
120
+ return yield* ownedRead({ fqn, instanceId, output }, found, ours);
115
121
  }),
116
122
 
117
123
  /**
@@ -131,9 +137,8 @@ export const BaoCloudflareRoleProvider = () =>
131
137
  * and the old id leaves the stack as an orphan delete, which `retain` keeps live.
132
138
  */
133
139
  diff: Effect.fn(function* ({ news, olds, output }) {
134
- const tried = triedRolePath(output, olds, rolePath);
135
- const declared = declaredRolePath(news, rolePath);
136
- const move = yield* judgeMove('Bao.CloudflareRole', tried, declared, (path) => path);
140
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
141
+ // ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
137
142
  if (output === undefined) return undefined;
138
143
  if (move !== undefined) return { action: 'replace' } as const;
139
144
  if (!isResolved(news)) return undefined;
@@ -150,19 +155,19 @@ export const BaoCloudflareRoleProvider = () =>
150
155
  : ({ action: 'update' } as const);
151
156
  }),
152
157
 
153
- reconcile: Effect.fn(function* ({ news, output }) {
158
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
154
159
  const path = rolePath(news.mount, news.name);
155
160
  // ⛔ An `update` across a move the diff could not see — refused before any write.
156
- const before = output === undefined ? path : rolePath(output.mount, output.name);
157
- if (isMoved(before, path) === true) {
158
- return yield* refuseMovedUpdate('Bao.CloudflareRole', before, path);
159
- }
161
+ yield* guardRename(IDENTITY, news, output);
160
162
  const refusal = refusalOf(news);
161
163
  if (refusal !== undefined) {
162
164
  return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
163
165
  }
164
166
  const policies = yield* resolved(news);
165
167
  const live = yield* readCloudflareRole(news.mount, news.name);
168
+ if (live !== undefined) {
169
+ yield* refuseTakeover({ fqn, instanceId, output }, `Bao.CloudflareRole ${path}`);
170
+ }
166
171
  /**
167
172
  * ⛔ ADOPTING A ROLE THAT ALREADY MATCHES MUST NOT REWRITE IT. Alchemy's `adopted` action
168
173
  * routes through reconcile (policy.ts has the Apply.ts reading), so without this guard
@@ -207,5 +212,5 @@ export const BaoCloudflareRoleProvider = () =>
207
212
  return undefined;
208
213
  }),
209
214
  });
210
- }),
215
+ }).pipe(Effect.map(provingResumes)),
211
216
  );
@@ -82,3 +82,55 @@ export const runFailure = <A, E>(
82
82
  env: BaoEnvironment,
83
83
  effect: Effect.Effect<A, E, HttpClient.HttpClient>,
84
84
  ) => Effect.runPromise(provided(env, Effect.flip(effect)));
85
+
86
+ /**
87
+ * An injected crash between a write and its commit. Once armed, `crashNext()` fails the first read
88
+ * that follows a write with a 500 — a reconcile whose write landed and whose read-back, and so the
89
+ * engine's commit, never did — and `crashNext('write')` lands the next write and then answers it
90
+ * 500, for a reconcile that reads nothing back (Bao.Policy). Either leaves the `creating` /
91
+ * `replacing` row a killed deploy leaves.
92
+ */
93
+ export const crashAfterWrite = () => {
94
+ let armed: 'read-back' | 'write' | undefined;
95
+ let wrote = false;
96
+ const CRASH: Reply = { json: { errors: ['injected: the deploy died here'] }, status: 500 };
97
+ return {
98
+ crashNext: (at: 'read-back' | 'write' = 'read-back') => {
99
+ armed = at;
100
+ wrote = false;
101
+ },
102
+ wrap:
103
+ (answer: (seen: Seen) => Reply) =>
104
+ (seen: Seen): Reply => {
105
+ if (armed === 'write' && seen.method !== 'GET') {
106
+ armed = undefined;
107
+ answer(seen);
108
+ return CRASH;
109
+ }
110
+ if (armed !== undefined && seen.method !== 'GET') wrote = true;
111
+ if (armed !== undefined && wrote && seen.method === 'GET') {
112
+ armed = undefined;
113
+ return CRASH;
114
+ }
115
+ return answer(seen);
116
+ },
117
+ };
118
+ };
119
+
120
+ /**
121
+ * A deploy killed BEFORE a resource's reconcile ran: the first write to a path ending in `suffix` is
122
+ * refused (500) without landing. Aimed at an upstream, it fails every create waiting on it after
123
+ * Apply has committed their `creating` rows — rows whose create never asked whose object sat at
124
+ * its identity (ownership/whole.ts).
125
+ */
126
+ export const refuseWriteOnce = (suffix: string) => {
127
+ let armed = true;
128
+ return (answer: (seen: Seen) => Reply) =>
129
+ (seen: Seen): Reply => {
130
+ if (armed && seen.method !== 'GET' && seen.path.endsWith(suffix)) {
131
+ armed = false;
132
+ return { json: { errors: ['injected: the upstream was refused'] }, status: 500 };
133
+ }
134
+ return answer(seen);
135
+ };
136
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The five families fake-families.ts does not cover — Bao.Policy, Bao.CloudflareRole,
3
+ * Bao.ProxmoxRole, Bao.Mount and Bao.AuthMethod — as rows of one table over one routed fake, so the
4
+ * ownership tests (adopt-core.test.ts) drive each through Alchemy's own plan and apply. Each row
5
+ * declares one object whose identity is fixed; `knob` is a duration-like prop that is NOT its
6
+ * identity, so an Output can stand in for it and a second owner can change it.
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import type * as Output from 'alchemy/Output';
14
+ import { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
15
+ import { BaoAuthRoleProvider } from './auth-role.ts';
16
+ import { groupKey } from './cloudflare-group-scope.ts';
17
+ import { CloudflarePermissionGroups } from './cloudflare-permission-groups.ts';
18
+ import { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
19
+ import type { Reply, Seen } from './fake-bao.ts';
20
+ import { aclPolicies, cloudflareRoles, fakePolicyProviders, proxmoxRoles } from './fake-engines.ts';
21
+ import { wireRoles } from './fake-engines-roles.ts';
22
+ import { liveTable } from './fake-mounts.ts';
23
+ import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
24
+ import { BaoMount, BaoMountProvider } from './mount.ts';
25
+ import { BaoPolicy } from './policy.ts';
26
+ import { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
27
+
28
+ /** Every group resolves, to an id named after it — these tests are about ownership, not groups. */
29
+ const groups = Layer.succeed(CloudflarePermissionGroups, {
30
+ resolve: (_mount, refs) =>
31
+ Effect.succeed(new Map(refs.map((ref) => [groupKey(ref.name, ref.scope), `id-${ref.name}`]))),
32
+ });
33
+
34
+ export const coreProviders = Layer.mergeAll(
35
+ fakePolicyProviders,
36
+ BaoCloudflareRoleProvider().pipe(Layer.provide(groups)),
37
+ BaoProxmoxRoleProvider(),
38
+ BaoMountProvider(),
39
+ BaoAuthMethodProvider(),
40
+ // ★ For `upstream`: an AppRole whose `tokenTtl` is the Output a knob is fed.
41
+ BaoAuthRoleProvider(),
42
+ FetchHttpClient.layer,
43
+ );
44
+
45
+ /** Every store behind one answer, routed by path. */
46
+ export const coreEstate = () => {
47
+ const stores = {
48
+ auth: liveTable('sys/auth', {}),
49
+ cloudflare: cloudflareRoles(),
50
+ mounts: liveTable('sys/mounts', {}),
51
+ policies: aclPolicies(),
52
+ proxmox: proxmoxRoles(),
53
+ roles: wireRoles(),
54
+ };
55
+ const answer = (seen: Seen): Reply => {
56
+ const path = seen.path.replace(/^\/v1\//, '');
57
+ if (path.startsWith('sys/policies/acl/')) return stores.policies(seen);
58
+ if (path.startsWith('sys/auth')) return stores.auth.answer(seen);
59
+ if (path.startsWith('sys/mounts') || path.startsWith('sys/remount')) {
60
+ return stores.mounts.answer(seen);
61
+ }
62
+ if (path.startsWith('cloudflare-acme-dns/')) return stores.cloudflare(seen);
63
+ if (path.startsWith('proxmox-lab/')) return stores.proxmox(seen);
64
+ return stores.roles(seen);
65
+ };
66
+ return Object.assign(answer, stores);
67
+ };
68
+
69
+ export type CoreEstate = ReturnType<typeof coreEstate>;
70
+
71
+ type Text = string | Output.Output<string>;
72
+
73
+ export interface CoreRow {
74
+ readonly family: string;
75
+ /** One resource, logical id `id`, at the row's fixed identity. */
76
+ readonly declare: (id: string, knob: Text) => StackBody;
77
+ /** Whether the fake holds the object. */
78
+ readonly has: (estate: CoreEstate) => boolean;
79
+ }
80
+
81
+ export const CORE: readonly CoreRow[] = [
82
+ {
83
+ // ★ The fragments directory IS the content (fake-engines.ts), so a knob changes the policy.
84
+ declare: (id, knob) => BaoPolicy(id, { fragments: knob, name: 'app' }),
85
+ family: 'Bao.Policy',
86
+ has: (estate) => estate.policies.live.has('sys/policies/acl/app'),
87
+ },
88
+ {
89
+ declare: (id, knob) =>
90
+ BaoCloudflareRole(id, {
91
+ description: 'Read DNS records on one zone. (zone: example.com)',
92
+ maxTtl: '1h',
93
+ mount: 'cloudflare-acme-dns',
94
+ name: 'dns-read',
95
+ policies: [
96
+ {
97
+ effect: 'allow',
98
+ groupOrder: 'declared',
99
+ groups: ['DNS Read'],
100
+ resource: 'com.cloudflare.api.account.zone.z1',
101
+ },
102
+ ],
103
+ ttl: knob,
104
+ }),
105
+ family: 'Bao.CloudflareRole',
106
+ has: (estate) => estate.cloudflare.live.has('cloudflare-acme-dns/roles/dns-read'),
107
+ },
108
+ {
109
+ declare: (id, knob) =>
110
+ BaoProxmoxRole(id, {
111
+ maxTtl: '6h',
112
+ mintUser: 'reader@pve',
113
+ mount: 'proxmox-lab',
114
+ name: 'read',
115
+ ttl: knob,
116
+ }),
117
+ family: 'Bao.ProxmoxRole',
118
+ has: (estate) => estate.proxmox.live.has('proxmox-lab/roles/read'),
119
+ },
120
+ {
121
+ declare: (id, knob) => BaoMount(id, { defaultLeaseTtl: knob, path: 'kv', type: 'kv' }),
122
+ family: 'Bao.Mount',
123
+ has: (estate) => estate.mounts.table.has('kv/'),
124
+ },
125
+ {
126
+ declare: (id, knob) => BaoAuthMethod(id, { defaultLeaseTtl: knob, path: 'jwt', type: 'jwt' }),
127
+ family: 'Bao.AuthMethod',
128
+ has: (estate) => estate.auth.table.has('jwt/'),
129
+ },
130
+ ];
131
+
132
+ /** One stack over a fresh core estate, plus `owner`: a second stack over the same fake. */
133
+ export const withCore = (
134
+ body: (stack: FakeStack, estate: CoreEstate, seen: Seen[], owner: FakeStack) => Promise<void>,
135
+ answer: (estate: CoreEstate) => (seen: Seen) => Reply = (estate) => estate,
136
+ ): Promise<void> => {
137
+ const estate = coreEstate();
138
+ return withFakeStack(coreProviders, answer(estate), (stack, bao) =>
139
+ body(stack, estate, bao.seen, fakeStack(coreProviders, { BAO_ADDR: bao.address }, 'Owner')),
140
+ );
141
+ };