@homeflare/alchemy 0.6.0 → 0.8.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 (219) hide show
  1. package/README.md +74 -41
  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 +91 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +34 -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 +54 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +22 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +970 -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 +51 -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 +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-sudo.d.ts +50 -0
  53. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +18 -9
  55. package/dist/launchd/host-effect.d.ts.map +1 -1
  56. package/dist/launchd/host-file-form.d.ts +5 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -1
  58. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  59. package/dist/launchd/index.d.ts +2 -0
  60. package/dist/launchd/index.d.ts.map +1 -1
  61. package/dist/launchd/index.js +342 -8
  62. package/dist/launchd/index.js.map +16 -12
  63. package/dist/launchd/job-preflight.d.ts.map +1 -1
  64. package/dist/launchd/job-validate.d.ts +2 -0
  65. package/dist/launchd/job-validate.d.ts.map +1 -1
  66. package/dist/launchd/local-runner.d.ts +2 -0
  67. package/dist/launchd/local-runner.d.ts.map +1 -1
  68. package/dist/launchd/providers.d.ts +2 -1
  69. package/dist/launchd/providers.d.ts.map +1 -1
  70. package/dist/launchd/runner.d.ts +7 -5
  71. package/dist/launchd/runner.d.ts.map +1 -1
  72. package/dist/launchd/secret-tripwire.d.ts +1 -0
  73. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  74. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  75. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  76. package/dist/launchd/sudo-guard.d.ts +56 -0
  77. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  78. package/dist/launchd/sudo-runner.d.ts +60 -0
  79. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  80. package/dist/launchd/sudo-stage.d.ts +7 -0
  81. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  82. package/dist/openbao/auth-role-form.d.ts +0 -9
  83. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  84. package/dist/openbao/auth-role.d.ts +3 -2
  85. package/dist/openbao/auth-role.d.ts.map +1 -1
  86. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  87. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  88. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  89. package/dist/openbao/fake-engines.d.ts +36 -0
  90. package/dist/openbao/fake-engines.d.ts.map +1 -0
  91. package/dist/openbao/fake-families.d.ts +49 -0
  92. package/dist/openbao/fake-families.d.ts.map +1 -0
  93. package/dist/openbao/fake-stack.d.ts +29 -0
  94. package/dist/openbao/fake-stack.d.ts.map +1 -0
  95. package/dist/openbao/host-approles.d.ts.map +1 -1
  96. package/dist/openbao/index.js +393 -234
  97. package/dist/openbao/index.js.map +23 -19
  98. package/dist/openbao/jwt-config.d.ts +5 -0
  99. package/dist/openbao/jwt-config.d.ts.map +1 -1
  100. package/dist/openbao/jwt-role.d.ts +2 -1
  101. package/dist/openbao/jwt-role.d.ts.map +1 -1
  102. package/dist/openbao/kubernetes-role.d.ts +2 -1
  103. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  104. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  105. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  106. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  107. package/dist/openbao/pki-role.d.ts.map +1 -1
  108. package/dist/openbao/plugin.d.ts.map +1 -1
  109. package/dist/openbao/policy.d.ts +2 -0
  110. package/dist/openbao/policy.d.ts.map +1 -1
  111. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  112. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  113. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  114. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  115. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  116. package/dist/openbao/rename-identity.d.ts +69 -0
  117. package/dist/openbao/rename-identity.d.ts.map +1 -0
  118. package/dist/openbao/rename.d.ts +107 -0
  119. package/dist/openbao/rename.d.ts.map +1 -0
  120. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  121. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  122. package/dist/openbao/ssh-role.d.ts.map +1 -1
  123. package/docs/caddy-admin.md +24 -0
  124. package/docs/caddy.md +199 -0
  125. package/docs/launchd-sudo.md +122 -0
  126. package/docs/launchd.md +7 -4
  127. package/docs/mesh-node.md +188 -0
  128. package/docs/peers.md +39 -0
  129. package/package.json +7 -2
  130. package/src/caddy/admin-calls.ts +128 -0
  131. package/src/caddy/admin-guard.ts +170 -0
  132. package/src/caddy/admin.ts +74 -0
  133. package/src/caddy/adopt-scope.test.ts +82 -0
  134. package/src/caddy/caddyfile-tripwire.ts +133 -0
  135. package/src/caddy/config-adopt.test.ts +235 -0
  136. package/src/caddy/config-form.ts +64 -0
  137. package/src/caddy/config-lifecycle.test.ts +196 -0
  138. package/src/caddy/config-lifecycle.ts +239 -0
  139. package/src/caddy/config.ts +153 -0
  140. package/src/caddy/digest.ts +44 -0
  141. package/src/caddy/fake-caddy.ts +179 -0
  142. package/src/caddy/guards.test.ts +156 -0
  143. package/src/caddy/index.ts +26 -0
  144. package/src/caddy/local-admin.test.ts +160 -0
  145. package/src/caddy/local-admin.ts +189 -0
  146. package/src/caddy/providers.test.ts +229 -0
  147. package/src/caddy/providers.ts +17 -0
  148. package/src/caddy/with-file.ts +66 -0
  149. package/src/cloudflare/fake-mesh.ts +181 -0
  150. package/src/cloudflare/index.ts +9 -3
  151. package/src/cloudflare/mesh-node-api.ts +127 -0
  152. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  153. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  154. package/src/cloudflare/mesh-node-form.ts +115 -0
  155. package/src/cloudflare/mesh-node-harness.ts +124 -0
  156. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  157. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  158. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  159. package/src/cloudflare/mesh-node-token.ts +125 -0
  160. package/src/cloudflare/mesh-node.test.ts +172 -0
  161. package/src/cloudflare/mesh-node.ts +120 -0
  162. package/src/cloudflare/providers.ts +20 -2
  163. package/src/index.ts +1 -0
  164. package/src/launchd/fake-sudo.ts +136 -0
  165. package/src/launchd/host-effect.ts +33 -1
  166. package/src/launchd/host-file-form.ts +10 -2
  167. package/src/launchd/host-file-lifecycle.ts +2 -1
  168. package/src/launchd/index.ts +2 -0
  169. package/src/launchd/job-preflight.ts +3 -2
  170. package/src/launchd/job-validate.ts +1 -1
  171. package/src/launchd/local-runner.ts +6 -3
  172. package/src/launchd/providers.test.ts +3 -0
  173. package/src/launchd/providers.ts +2 -1
  174. package/src/launchd/runner.ts +7 -5
  175. package/src/launchd/secret-tripwire.ts +1 -1
  176. package/src/launchd/sudo-allowlist.test.ts +191 -0
  177. package/src/launchd/sudo-allowlist.ts +213 -0
  178. package/src/launchd/sudo-guard.test.ts +100 -0
  179. package/src/launchd/sudo-guard.ts +151 -0
  180. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  181. package/src/launchd/sudo-runner-files.test.ts +240 -0
  182. package/src/launchd/sudo-runner.test.ts +126 -0
  183. package/src/launchd/sudo-runner.ts +235 -0
  184. package/src/launchd/sudo-stage.ts +28 -0
  185. package/src/openbao/README.md +8 -2
  186. package/src/openbao/REPLACE.md +126 -22
  187. package/src/openbao/auth-role-form.test.ts +2 -16
  188. package/src/openbao/auth-role-form.ts +0 -11
  189. package/src/openbao/auth-role.ts +35 -14
  190. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  191. package/src/openbao/cloudflare-role.ts +22 -8
  192. package/src/openbao/fake-engines-roles.ts +114 -0
  193. package/src/openbao/fake-engines.ts +126 -0
  194. package/src/openbao/fake-families.ts +182 -0
  195. package/src/openbao/fake-stack.ts +112 -0
  196. package/src/openbao/host-approles.test.ts +15 -2
  197. package/src/openbao/host-approles.ts +19 -4
  198. package/src/openbao/jwt-config.ts +26 -5
  199. package/src/openbao/jwt-role.ts +24 -8
  200. package/src/openbao/kubernetes-role.ts +20 -8
  201. package/src/openbao/mfa-enforcement.ts +23 -8
  202. package/src/openbao/mfa-totp.ts +21 -8
  203. package/src/openbao/pki-role.ts +19 -7
  204. package/src/openbao/plugin.ts +41 -15
  205. package/src/openbao/policy-rename.test.ts +112 -0
  206. package/src/openbao/policy-wire.test.ts +7 -26
  207. package/src/openbao/policy.ts +27 -4
  208. package/src/openbao/proxmox-role-form.ts +27 -8
  209. package/src/openbao/proxmox-role-wire.ts +24 -0
  210. package/src/openbao/proxmox-role.ts +52 -36
  211. package/src/openbao/rename-adoption.test.ts +82 -0
  212. package/src/openbao/rename-families.test.ts +122 -0
  213. package/src/openbao/rename-identity.test.ts +129 -0
  214. package/src/openbao/rename-identity.ts +117 -0
  215. package/src/openbao/rename-occupied.test.ts +166 -0
  216. package/src/openbao/rename.ts +166 -0
  217. package/src/openbao/role-rename.test.ts +205 -0
  218. package/src/openbao/ssh-role-erase.ts +50 -0
  219. package/src/openbao/ssh-role.ts +16 -54
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Renaming a Bao.Policy, through Alchemy's own plan and apply (fake-stack.ts) against a fake policy
3
+ * store keyed as OpenBao keys it (fake-engines.ts). Before 2026-09-21 the second deploy of every
4
+ * rename below planned `update` and left the old policy live, under `destroy` too.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import * as Output from 'alchemy/Output';
9
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
10
+ import * as Effect from 'effect/Effect';
11
+ import type { Fake } from './fake-bao.ts';
12
+ import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
13
+ import { type FakeStack, type StackBody, withFakeStack, writesOf as writes } from './fake-stack.ts';
14
+ import { BaoPolicy } from './policy.ts';
15
+
16
+ /** A fake policy store and a stack over it; `live` lists the policy names the store holds. */
17
+ const withStack = (body: (stack: FakeStack, bao: Fake, live: () => string[]) => Promise<void>) => {
18
+ const store = aclPolicies();
19
+ return withFakeStack(fakePolicyProviders, store, (stack, bao) =>
20
+ body(stack, bao, () =>
21
+ [...store.live.keys()].map((path) => path.replace('sys/policies/acl/', '')).sort(),
22
+ ),
23
+ );
24
+ };
25
+
26
+ type Removal = typeof RemovalPolicy.destroy;
27
+ const policy = (name: string, removal: Removal = RemovalPolicy.retain): StackBody =>
28
+ Effect.asVoid(BaoPolicy('Policy', { fragments: '/policies/app', name }).pipe(removal()));
29
+
30
+ describe('Bao.Policy rename, through the engine', () => {
31
+ it('plans replace, writes the new name first, then deletes the OLD name under destroy', async () => {
32
+ await withStack(async (stack, bao, live) => {
33
+ assert.deepEqual(await stack.deploy(policy('app-old', RemovalPolicy.destroy)), {
34
+ Policy: 'create',
35
+ });
36
+ bao.seen.length = 0;
37
+ assert.deepEqual(await stack.deploy(policy('app-new', RemovalPolicy.destroy)), {
38
+ Policy: 'replace',
39
+ });
40
+ assert.deepEqual(writes(bao.seen), [
41
+ 'PUT /v1/sys/policies/acl/app-new',
42
+ 'DELETE /v1/sys/policies/acl/app-old',
43
+ ]);
44
+ assert.deepEqual(live(), ['app-new']);
45
+ });
46
+ });
47
+
48
+ it('still plans replace under the default retain, and keeps the old policy live', async () => {
49
+ await withStack(async (stack, bao, live) => {
50
+ await stack.deploy(policy('app-old'));
51
+ assert.deepEqual(await stack.deploy(policy('app-new')), { Policy: 'replace' });
52
+ assert.ok(!writes(bao.seen).some((each) => each.startsWith('DELETE')));
53
+ assert.deepEqual(live(), ['app-new', 'app-old']);
54
+ });
55
+ });
56
+
57
+ it('treats a change of case as the same policy: noop, and nothing deleted', async () => {
58
+ await withStack(async (stack, bao, live) => {
59
+ await stack.deploy(policy('App', RemovalPolicy.destroy));
60
+ bao.seen.length = 0;
61
+ assert.deepEqual(await stack.deploy(policy('app', RemovalPolicy.destroy)), {
62
+ Policy: 'noop',
63
+ });
64
+ assert.deepEqual(writes(bao.seen), []);
65
+ assert.deepEqual(live(), ['app']);
66
+ });
67
+ });
68
+
69
+ it('plans replace while `fragments` is still an Output of a changing upstream', async () => {
70
+ const body = (dir: string, name: string): StackBody =>
71
+ Effect.gen(function* () {
72
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
73
+ yield* BaoPolicy('Policy', {
74
+ fragments: Output.interpolate`/policies/${upstream.grants}`,
75
+ name,
76
+ }).pipe(RemovalPolicy.destroy());
77
+ });
78
+ await withStack(async (stack, _bao, live) => {
79
+ await stack.deploy(body('/policies/one', 'app-old'));
80
+ const planned = await stack.deploy(body('/policies/two', 'app-new'));
81
+ assert.deepEqual(planned, { Policy: 'replace', Upstream: 'update' });
82
+ assert.deepEqual(live(), ['app-new', 'upstream']);
83
+ });
84
+ });
85
+
86
+ it('refuses an update across a rename it could not see, then replaces on the next deploy', async () => {
87
+ // ★ The name is an Output of an upstream that is updating, so the diff cannot see it at plan.
88
+ const body = (dir: string): StackBody =>
89
+ Effect.gen(function* () {
90
+ const upstream = yield* BaoPolicy('Upstream', { fragments: dir, name: 'upstream' });
91
+ yield* BaoPolicy('Policy', {
92
+ fragments: '/policies/app',
93
+ name: Output.interpolate`app-${upstream.grants}`,
94
+ }).pipe(RemovalPolicy.destroy());
95
+ });
96
+ await withStack(async (stack, bao, live) => {
97
+ await stack.deploy(body('/policies/one'));
98
+ assert.deepEqual(live(), ['app-1', 'upstream']);
99
+ bao.seen.length = 0;
100
+ await assert.rejects(
101
+ stack.deploy(body('/policies/two')),
102
+ /identity moved from app-1 to app-2/,
103
+ );
104
+ assert.deepEqual(writes(bao.seen), ['PUT /v1/sys/policies/acl/upstream']);
105
+ assert.deepEqual(await stack.deploy(body('/policies/two')), {
106
+ Policy: 'replace',
107
+ Upstream: 'noop',
108
+ });
109
+ assert.deepEqual(live(), ['app-2', 'upstream']);
110
+ });
111
+ });
112
+ });
@@ -1,43 +1,24 @@
1
1
  /**
2
- * Bao.Policy's calls against a fake that behaves like OpenBao where it matters: it STRIPS the
3
- * policy's trailing newline on write (measured on the live server — see digest.ts), answers 404 for
4
- * a policy it does not have, and 204 for a delete whether or not the policy existed.
2
+ * Bao.Policy's calls against a fake that behaves like OpenBao where it matters (fake-engines.ts): it
3
+ * STRIPS the policy's trailing newline on write (measured on the live server — see digest.ts),
4
+ * answers 404 for a policy it does not have, and 204 for a delete whether or not it existed.
5
5
  */
6
6
  import assert from 'node:assert/strict';
7
7
  import { describe, it } from 'node:test';
8
8
  import { BaoError } from './bao-status.ts';
9
9
  import { sha256 } from './digest.ts';
10
- import { type Seen, run, runFailure, withFake } from './fake-bao.ts';
10
+ import { run, runFailure, withFake } from './fake-bao.ts';
11
+ import { aclPolicies } from './fake-engines.ts';
11
12
  import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
12
13
 
13
14
  const PREFIX = '/v1/sys/policies/acl/';
14
15
 
15
- const policyStore = () => {
16
- const policies = new Map<string, string>();
17
- return (seen: Seen) => {
18
- const name = seen.path.slice(PREFIX.length);
19
- if (seen.method === 'PUT') {
20
- const { policy } = JSON.parse(seen.body) as { policy: string };
21
- policies.set(name, policy.replace(/\n+$/, ''));
22
- return { status: 204 };
23
- }
24
- if (seen.method === 'DELETE') {
25
- policies.delete(name);
26
- return { status: 204 };
27
- }
28
- const policy = policies.get(name);
29
- return policy === undefined
30
- ? { json: { errors: [] }, status: 404 }
31
- : { json: { data: { name, policy } }, status: 200 };
32
- };
33
- };
34
-
35
16
  /** Two fragments, each followed by a newline — exactly how policy.ts assembles them. */
36
17
  const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "kv/data/b" {}\n';
37
18
 
38
19
  describe('policy wire', () => {
39
20
  it('reads a policy that was never written as absent', async () => {
40
- await withFake(policyStore(), async (bao) => {
21
+ await withFake(aclPolicies(), async (bao) => {
41
22
  assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), '');
42
23
  });
43
24
  });
@@ -45,7 +26,7 @@ describe('policy wire', () => {
45
26
  // ⛔ THE FOREVER-DIFF. Live text comes back one newline short; the digests must still agree, or
46
27
  // every plan says `update` and reconcile rewrites a policy that already matches.
47
28
  it('round-trips through the stripped newline with equal digests', async () => {
48
- await withFake(policyStore(), async (bao) => {
29
+ await withFake(aclPolicies(), async (bao) => {
49
30
  const env = { BAO_ADDR: bao.address };
50
31
  await run(env, writePolicy('homeflare-llm', ASSEMBLED));
51
32
  assert.equal(bao.seen[0]?.method, 'PUT');
@@ -7,7 +7,9 @@ import * as Path from 'effect/Path';
7
7
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
8
8
  import { sha256 } from './digest.ts';
9
9
  import { isEmptyAssembly } from './policy-assembly.ts';
10
- import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
10
+ import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
11
+ import { policyKey } from './rename.ts';
12
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
11
13
 
12
14
  /**
13
15
  * An OpenBao ACL policy, assembled from the HCL fragments that declare it.
@@ -91,11 +93,16 @@ export interface BaoPolicy extends Resource<
91
93
  * orphaned resource; a caller that means it opts in with `.pipe(RemovalPolicy.destroy())`. This
92
94
  * is Terraform's `prevent_destroy`, not a stubbed operation — a delete that silently did nothing
93
95
  * would lie to whoever read the plan.
96
+ * ⚠️ `retain` ALSO KEEPS THE OLD GENERATION OF A RENAME. A new `name` plans `replace`, and under
97
+ * `retain` the old policy is left live and unmanaged (rename.ts, REPLACE.md).
94
98
  */
95
99
  export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
96
100
  defaultRemovalPolicy: 'retain',
97
101
  });
98
102
 
103
+ /** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
104
+ const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
105
+
99
106
  /** `path "..."` grant count — the same number the apply script prints. */
100
107
  const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
101
108
 
@@ -141,11 +148,25 @@ export const BaoPolicyProvider = () =>
141
148
  * OpenBao UI is exactly the drift the check-* gates exist to catch, and a
142
149
  * provider that trusted its own state would report `noop` straight through it.
143
150
  */
144
- diff: Effect.fn(function* ({ news, output }) {
151
+ diff: Effect.fn(function* ({ news, olds, output }) {
152
+ /**
153
+ * ⛔ A RENAMED POLICY IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
154
+ * 2026-09-21 this read the new name, found nothing and planned `update`: the new policy
155
+ * was written and the old one kept every grant under no state record. Names compare as
156
+ * OpenBao keys them (`policyKey`), so a change of case is the same policy, never a
157
+ * replace. ⛔ A rename onto a policy that already exists fails the plan (`judgeMove`).
158
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD POLICY STAYS LIVE, GRANTS AND ALL, for every token,
159
+ * role and group that still names it. Under `destroy` they lose those grants at the
160
+ * delete, so a role outside this graph that names the old policy must move in the same
161
+ * PR (REPLACE.md).
162
+ */
163
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
164
+ if (output === undefined) return undefined;
165
+ if (move !== undefined) return { action: 'replace' } as const;
145
166
  // ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
146
167
  // own providers guard with isResolved and skip rather than guess; a diff that
147
168
  // read a Config as a string would compare a placeholder to real HCL.
148
- if (output === undefined || !isResolved(news)) return undefined;
169
+ if (!isResolved(news)) return undefined;
149
170
  const { joined } = yield* assemble(news.fragments);
150
171
  const live = yield* readPolicy(news.name);
151
172
  return live.length > 0 && sha256(live) === sha256(joined)
@@ -153,8 +174,10 @@ export const BaoPolicyProvider = () =>
153
174
  : ({ action: 'update' } as const);
154
175
  }),
155
176
 
156
- reconcile: Effect.fn(function* ({ news }) {
177
+ reconcile: Effect.fn(function* ({ news, output }) {
157
178
  const name = news.name;
179
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
180
+ yield* guardRename(IDENTITY, news, output);
158
181
  const { joined, parts } = yield* assemble(news.fragments);
159
182
  /**
160
183
  * ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
@@ -109,7 +109,7 @@ const ttlText = (seconds: number | undefined, fallback = '0') => {
109
109
  export const rolePath = (mount: string, name: string) => `${mountPath(mount)}/roles/${name}`;
110
110
 
111
111
  export const attributesOf = (
112
- props: BaoProxmoxRoleProps,
112
+ props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>,
113
113
  live: Record<string, unknown>,
114
114
  ): BaoProxmoxRoleAttributes => {
115
115
  const attrs = {
@@ -129,13 +129,13 @@ export const attributesOf = (
129
129
  * The body for `PUT <mount>/roles/<name>` — all strings, the `k=v` pairs `bao write` sent — or
130
130
  * the prop names whose duration would not parse.
131
131
  *
132
- * ⛔ A WRITE TO roles/{name} REPLACES THE ROLE, IT DOES NOT MERGE. An omitted `ttl` is written
133
- * as the field's zero value, so a resource that treated undeclared TTLs as "unmanaged" — the
134
- * Bao.Mount shape — would silently reset a lease ceiling every time it rewrote the role for an
135
- * unrelated reason. Both TTLs are therefore required props and EVERY write carries all three
136
- * fields. REASONED from the Vault secrets-framework write semantics plus this plugin's schema
137
- * marking only `mint_user` required; NOT measured, because this session was read-only and
138
- * proving it would have meant writing to the live engine.
132
+ * ⛔ EVERY WRITE CARRIES ALL THREE FIELDS, and both TTLs are required props, so `matches` covers
133
+ * the whole role and no field is left unmanaged behind a green plan.
134
+ * ⚠️ CORRECTED 2026-09-21 FROM THE PLUGIN SOURCE. This said a write REPLACES the role and resets an
135
+ * omitted TTL to zero. The port in homeflare-openbao-plugins (secrets/proxmox/path_roles.go,
136
+ * pathRolesWrite, commit 0e79154) MERGES: it loads the stored role and sets only the fields
137
+ * present (`d.GetOk`). An omitted TTL would keep its old value, which is still a value this
138
+ * resource would not be managing. The build running live was not re-checked against that source.
139
139
  *
140
140
  * ⚠️ Seconds go on the wire as integers, not as `1h`. The schema declares both TTLs
141
141
  * `"format": "seconds"`, and an integer is accepted unambiguously by every path that reads
@@ -167,6 +167,25 @@ const sameTtl = (want: string, have: string) => {
167
167
  return wantSeconds !== undefined && wantSeconds === haveSeconds;
168
168
  };
169
169
 
170
+ /**
171
+ * Why moving the role's mint user from `before` to the declared one must be refused, or undefined.
172
+ * The ⛔ on reconcile in proxmox-role.ts says why a re-scope needs `allowMintUserChange`.
173
+ *
174
+ * ★ ONE DEFINITION FOR BOTH PLACES THAT ASK. Reconcile asks it of the live role at the same path.
175
+ * The diff asks it across a rename, where `before` is the role being replaced: a rename writes a
176
+ * brand-new role, so reconcile alone would see nothing to re-scope, and a rename would become the
177
+ * way around the guard.
178
+ */
179
+ export const rescopeRefusal = (
180
+ before: string,
181
+ mintUser: string,
182
+ allowMintUserChange: string | undefined,
183
+ ): string | undefined =>
184
+ before === mintUser || allowMintUserChange === before
185
+ ? undefined
186
+ : `re-scopes mint_user ${before} → ${mintUser}. Every token this role mints inherits ` +
187
+ `the ACL of that PVE user. To re-scope deliberately, declare allowMintUserChange: '${before}'.`;
188
+
170
189
  /** True when the live role already matches the declaration on every field the plugin stores. */
171
190
  export const matches = (attributes: BaoProxmoxRoleAttributes, props: BaoProxmoxRoleProps) =>
172
191
  attributes.mintUser === props.mintUser &&
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The read Bao.ProxmoxRole makes of `<mount>/roles/<name>`, split from proxmox-role.ts so the diff,
3
+ * reconcile and the rename's re-scope check share one definition of "the live role".
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import { baoRead } from './bao-http.ts';
7
+ import { type BaoProxmoxRoleProps, attributesOf, rolePath } from './proxmox-role-form.ts';
8
+
9
+ /**
10
+ * The live role, or undefined when OpenBao answers 404.
11
+ *
12
+ * ⚠️ A missing MOUNT and a missing ROLE both read as undefined here, because both answer 404:
13
+ * an absent role is a read that found nothing, and a path under no mount is OpenBao's
14
+ * unsupported-path error, which is also a 404 (sdk/logical/response_util.go). They are not the
15
+ * same failure — one is a typo in `mount`, the other is a role that has yet to be created — so
16
+ * reconcile lets OpenBao's own `errors` through on the write rather than inventing a message.
17
+ * A bad mount says "no handler for route".
18
+ */
19
+ export const readRole = (props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>) =>
20
+ Effect.gen(function* () {
21
+ const live = yield* baoRead(rolePath(props.mount, props.name));
22
+ if (live === undefined) return undefined;
23
+ return attributesOf(props, live);
24
+ });
@@ -40,15 +40,18 @@ import { isResolved } from 'alchemy/Diff';
40
40
  import * as Provider from 'alchemy/Provider';
41
41
  import * as Effect from 'effect/Effect';
42
42
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
43
- import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
43
+ import { baoDelete, baoWrite } from './bao-http.ts';
44
44
  import {
45
45
  type BaoProxmoxRoleAttributes,
46
46
  type BaoProxmoxRoleProps,
47
- attributesOf,
48
47
  matches,
48
+ rescopeRefusal,
49
49
  rolePath,
50
50
  writeBody,
51
51
  } from './proxmox-role-form.ts';
52
+ import { readRole } from './proxmox-role-wire.ts';
53
+ import { declaredString, isPendingProp } from './rename.ts';
54
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
52
55
 
53
56
  export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
54
57
 
@@ -64,20 +67,8 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
64
67
  defaultRemovalPolicy: 'retain',
65
68
  });
66
69
 
67
- /**
68
- * ⚠️ A missing MOUNT and a missing ROLE both read as undefined here, because both answer 404:
69
- * an absent role is a read that found nothing, and a path under no mount is OpenBao's
70
- * unsupported-path error, which is also a 404 (sdk/logical/response_util.go). They are not the
71
- * same failure — one is a typo in `mount`, the other is a role that has yet to be created — so
72
- * reconcile lets OpenBao's own `errors` through on the write rather than inventing a message.
73
- * A bad mount says "no handler for route".
74
- */
75
- const readRole = (props: BaoProxmoxRoleProps) =>
76
- Effect.gen(function* () {
77
- const live = yield* baoRead(rolePath(props.mount, props.name));
78
- if (live === undefined) return undefined;
79
- return attributesOf(props, live);
80
- });
70
+ /** Exact: the plugin stores `roles/<name>` verbatim. */
71
+ const IDENTITY = roleIdentity<BaoProxmoxRoleAttributes>('Bao.ProxmoxRole', rolePath);
81
72
 
82
73
  export const BaoProxmoxRoleProvider = () =>
83
74
  Provider.effect(
@@ -103,8 +94,33 @@ export const BaoProxmoxRoleProvider = () =>
103
94
  * says everything is fine — exactly the drift the hand-written check-* gates exist to
104
95
  * catch, and exactly what a provider that trusted its own state would walk past.
105
96
  */
106
- diff: Effect.fn(function* ({ news, output }) {
107
- if (output === undefined || !isResolved(news)) return undefined;
97
+ diff: Effect.fn(function* ({ news, olds, output }) {
98
+ /**
99
+ * ⛔ A NEW `mount` OR `name` IS A `replace` (rename.ts). It is a DIFFERENT path, so none of
100
+ * the reasons below apply. Until 2026-09-21 it planned `update` and left the old role
101
+ * minting under no state record. Under the default `retain` the old role still mints;
102
+ * under `destroy` its consumers fail at the delete (REPLACE.md). ⛔ A move onto a role
103
+ * that already exists fails the plan (`judgeMove`).
104
+ * ⛔ THE RE-SCOPE GUARD TRAVELS WITH THE RENAME, checked here because the new generation's
105
+ * reconcile finds no live role to compare. While `mintUser` or `allowMintUserChange` is
106
+ * still an Output it cannot be checked, so the diff defers, and reconcile refuses the
107
+ * resulting `update`.
108
+ */
109
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
110
+ if (output === undefined) return undefined;
111
+ if (move !== undefined) {
112
+ const mintUser = declaredString(news, 'mintUser');
113
+ if (mintUser === undefined || isPendingProp(news, 'allowMintUserChange'))
114
+ return undefined;
115
+ const from = (yield* readRole(output))?.mintUser ?? output.mintUser;
116
+ const allow = declaredString(news, 'allowMintUserChange');
117
+ const refusal = rescopeRefusal(from, mintUser, allow);
118
+ if (refusal === undefined) return { action: 'replace' } as const;
119
+ return yield* Effect.die(
120
+ new Error(`Bao.ProxmoxRole ${move.from} → ${move.to}: the rename ${refusal}`),
121
+ );
122
+ }
123
+ if (!isResolved(news)) return undefined;
108
124
  const live = yield* readRole(news);
109
125
  if (live === undefined) return { action: 'update' } as const;
110
126
  if (matches(live, news)) return { action: 'noop' } as const;
@@ -126,7 +142,10 @@ export const BaoProxmoxRoleProvider = () =>
126
142
  return { action: 'update' } as const;
127
143
  }),
128
144
 
129
- reconcile: Effect.fn(function* ({ news }) {
145
+ reconcile: Effect.fn(function* ({ news, output }) {
146
+ const path = rolePath(news.mount, news.name);
147
+ // ⛔ An `update` across a move the diff could not see — refused before any read or write.
148
+ yield* guardRename(IDENTITY, news, output);
130
149
  const live = yield* readRole(news);
131
150
  /**
132
151
  * ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
@@ -137,20 +156,15 @@ export const BaoProxmoxRoleProvider = () =>
137
156
  * move unless the declaration NAMES THE VALUE IT IS REPLACING. Creating a role never
138
157
  * trips this — there is nothing to re-scope — and the permission expires by itself
139
158
  * once the change lands, because live then equals the declaration.
159
+ * ⚠️ A RENAME CREATES A NEW ROLE, SO IT NEVER REACHES THIS CHECK. The diff asks the same
160
+ * question of the role being replaced (`rescopeRefusal`, proxmox-role-form.ts).
140
161
  */
141
- if (
142
- live !== undefined &&
143
- live.mintUser !== news.mintUser &&
144
- news.allowMintUserChange !== live.mintUser
145
- ) {
146
- return yield* Effect.die(
147
- new Error(
148
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: live mint_user ` +
149
- `${live.mintUser} != declared ${news.mintUser}. Every token this role mints ` +
150
- 'inherits the ACL of that PVE user. To re-scope deliberately, declare ' +
151
- `allowMintUserChange: '${live.mintUser}'.`,
152
- ),
153
- );
162
+ const rescope =
163
+ live === undefined
164
+ ? undefined
165
+ : rescopeRefusal(live.mintUser, news.mintUser, news.allowMintUserChange);
166
+ if (rescope !== undefined) {
167
+ return yield* Effect.die(new Error(`Bao.ProxmoxRole ${path}: ${rescope}`));
154
168
  }
155
169
  if (live === undefined || !matches(live, news)) {
156
170
  const write = writeBody(news);
@@ -164,18 +178,18 @@ export const BaoProxmoxRoleProvider = () =>
164
178
  if (!write.ok) {
165
179
  return yield* Effect.die(
166
180
  new Error(
167
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: unparseable duration ` +
181
+ `Bao.ProxmoxRole ${path}: unparseable duration ` +
168
182
  `${write.bad.join(', ')}. Use 30s / 5m / 1h / 1d, or 0.`,
169
183
  ),
170
184
  );
171
185
  }
172
- yield* baoWrite('PUT', rolePath(news.mount, news.name), write.body);
186
+ yield* baoWrite('PUT', path, write.body);
173
187
  }
174
188
  const after = yield* readRole(news);
175
189
  if (after === undefined) {
176
190
  return yield* Effect.die(
177
191
  new Error(
178
- `Bao.ProxmoxRole ${rolePath(news.mount, news.name)}: write returned no error ` +
192
+ `Bao.ProxmoxRole ${path}: write returned no error ` +
179
193
  'but the role is still absent.',
180
194
  ),
181
195
  );
@@ -197,7 +211,9 @@ export const BaoProxmoxRoleProvider = () =>
197
211
  * So this is an availability change for every consumer of `creds/<name>` and NOT a
198
212
  * containment action: to contain a leaked credential, revoke the lease (which deletes
199
213
  * the PVE token) or delete the token in PVE. Deleting the role only hides the door it
200
- * came through. REASONED from Vault lease semantics; not exercised here.
214
+ * came through. The plugin's own test pins this (homeflare-openbao-plugins,
215
+ * TestDeletingARoleLeavesItsOutstandingLeasesRevocable): the delete is one storage
216
+ * delete, and each lease stays revocable without the role.
201
217
  *
202
218
  * ⚠️ IDEMPOTENT AS ALCHEMY REQUIRES — already gone is success. It used to get that by
203
219
  * going through `baoReadText`, which discarded the exit code entirely, so a REFUSED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The swap the plan-time guard cannot see: a NEW declaration of a name that is live. 🔴 MEASURED
3
+ * 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts). With no state row, the engine asks
4
+ * `read`, which finds the object and answers plain attributes, so Alchemy silently adopts it
5
+ * (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts). Nothing moved, so
6
+ * `judgeMove` never runs. Under `RemovalPolicy.destroy()` the old owner's delete then runs in
7
+ * Apply's phase 2, after the adoption has written, and removes the object the new declaration now
8
+ * claims. The deploy is green.
9
+ *
10
+ * ★ These pin that, and the two ways round it REPLACE.md gives: `renamedFrom` for a logical id that
11
+ * changes, and a second deploy for a name another resource is leaving. If one of the hazards stops
12
+ * reproducing, the engine changed: re-read REPLACE.md before relaxing it.
13
+ *
14
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+ import { renamedFrom } from 'alchemy';
19
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
20
+ import * as Effect from 'effect/Effect';
21
+ import { FAMILIES, rowOf, withEstate } from './fake-families.ts';
22
+ import { writesOf } from './fake-stack.ts';
23
+
24
+ const { destroy } = RemovalPolicy;
25
+
26
+ /** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
27
+ const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
28
+ const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
29
+
30
+ describe('a logical id changed without renamedFrom, under destroy', () => {
31
+ for (const row of FAMILIES) {
32
+ it(`${row.family}: the orphan delete removes what the new id just adopted`, async () => {
33
+ await withEstate(async (stack, estate) => {
34
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
35
+ const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
36
+ if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
37
+ else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
38
+ assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
39
+ });
40
+ });
41
+ }
42
+ });
43
+
44
+ describe('the ways round it', () => {
45
+ const row = rowOf('Bao.AuthRole');
46
+
47
+ it('renamedFrom migrates the state row instead: an update that deletes nothing', async () => {
48
+ await withEstate(async (stack, estate, seen) => {
49
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
50
+ seen.length = 0;
51
+ const moved = row.declare('Z', 'a', '15m', destroy).pipe(renamedFrom('X'));
52
+ assert.deepEqual(await stack.deploy(moved), { Z: 'update' });
53
+ assert.deepEqual(writesOf(seen), []);
54
+ assert.ok(row.has(estate, 'a'));
55
+ });
56
+ });
57
+
58
+ it('a name another resource moves off is lost when taken in the same deploy', async () => {
59
+ await withEstate(async (stack, estate) => {
60
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
61
+ const both = Effect.gen(function* () {
62
+ yield* row.declare('X', 'b', '15m', destroy);
63
+ yield* row.declare('Z', 'a', '30m', destroy);
64
+ });
65
+ assert.deepEqual(await stack.deploy(both), { X: 'replace', Z: 'adopted' });
66
+ assert.ok(row.has(estate, 'b') && !row.has(estate, 'a'));
67
+ });
68
+ });
69
+
70
+ it('and survives when taken in the deploy after the move', async () => {
71
+ await withEstate(async (stack, estate) => {
72
+ await stack.deploy(row.declare('X', 'a', '15m', destroy));
73
+ await stack.deploy(row.declare('X', 'b', '15m', destroy));
74
+ const both = Effect.gen(function* () {
75
+ yield* row.declare('X', 'b', '15m', destroy);
76
+ yield* row.declare('Z', 'a', '30m', destroy);
77
+ });
78
+ assert.deepEqual(await stack.deploy(both), { X: 'noop', Z: 'create' });
79
+ assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
80
+ });
81
+ });
82
+ });