@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
@@ -80,9 +80,21 @@ describe('hostAppRoles', () => {
80
80
  }
81
81
  });
82
82
 
83
- it('refuses unknown classes, duplicate hosts and names that would blur the separator', () => {
83
+ it('gives a host in two classes one role per class', () => {
84
+ const hosts = [
85
+ { class: 'pve-node', name: 'node-c' },
86
+ { class: 'operator-signer', name: 'node-c' },
87
+ ];
88
+ assert.deepEqual(
89
+ hostAppRoles({ ...INPUT, hosts }).map((role) => role.name),
90
+ ['operator-signer--node-c', 'pve-node--node-c'],
91
+ );
92
+ });
93
+
94
+ it('refuses unknown classes, a host twice in one class and names that blur the separator', () => {
84
95
  const bad = [
85
96
  { class: 'nope', name: 'node-c' },
97
+ { class: 'constructor', name: 'node-d' },
86
98
  { class: 'pve-node', name: 'node-a' },
87
99
  { class: 'pve-node', name: 'node-a' },
88
100
  { class: 'pve-node', name: 'node--x' },
@@ -93,7 +105,8 @@ describe('hostAppRoles', () => {
93
105
  (error: unknown) => {
94
106
  assert.ok(error instanceof Error);
95
107
  assert.match(error.message, /unknown class `nope`/);
96
- assert.match(error.message, /`node-a` is listed twice/);
108
+ assert.match(error.message, /unknown class `constructor`/);
109
+ assert.match(error.message, /`node-a` is listed twice in class `pve-node`/);
97
110
  assert.match(error.message, /`node--x` is not lowercase/);
98
111
  assert.match(error.message, /`Node-Y` is not lowercase/);
99
112
  return true;
@@ -96,22 +96,37 @@ export const hostAppRoles = (input: HostAppRolesInput): readonly BaoAuthRoleProp
96
96
  const problems = Object.entries(input.classes).flatMap(([name, spec]) =>
97
97
  classProblems(name, spec),
98
98
  );
99
+ /**
100
+ * ⛔ DEDUPED ON THE ROLE NAME, NOT THE HOST NAME. A host may sit in several classes (one machine
101
+ * that is both a signer and a node), and each class gives it its own role: `<class>--<host>`
102
+ * differs, so nothing collides. Until 2026-09-21 this keyed on the host alone and refused that
103
+ * estate outright. What it must still refuse is the same host twice in ONE class: two
104
+ * declarations of one role name, so one AppRole that two resources would each claim.
105
+ */
99
106
  const seen = new Set<string>();
100
107
  const roles: BaoAuthRoleProps[] = [];
101
108
  for (const host of input.hosts) {
102
109
  if (!NAME.test(host.name)) {
103
110
  problems.push(`host \`${host.name}\` is not lowercase letters, digits, single hyphens`);
104
111
  }
105
- if (seen.has(host.name)) problems.push(`host \`${host.name}\` is listed twice`);
106
- seen.add(host.name);
107
- const spec = input.classes[host.class];
112
+ const name = hostRoleName(host.class, host.name);
113
+ if (seen.has(name)) {
114
+ problems.push(`host \`${host.name}\` is listed twice in class \`${host.class}\``);
115
+ }
116
+ seen.add(name);
117
+ /**
118
+ * ⛔ OWN KEYS ONLY. `classes['constructor']` is Object's constructor, not undefined, so a host
119
+ * naming it passed as a class with no policies and no secretIdTtl, never checked by
120
+ * classProblems, and wrote a role whose secret_id never expires.
121
+ */
122
+ const spec = Object.hasOwn(input.classes, host.class) ? input.classes[host.class] : undefined;
108
123
  if (spec === undefined) {
109
124
  problems.push(`host \`${host.name}\` names unknown class \`${host.class}\``);
110
125
  continue;
111
126
  }
112
127
  roles.push({
113
128
  bindSecretId: true,
114
- name: hostRoleName(host.class, host.name),
129
+ name,
115
130
  secretIdNumUses: spec.secretIdNumUses ?? 0,
116
131
  secretIdTtl: spec.secretIdTtl,
117
132
  tokenMaxTtl: spec.tokenMaxTtl,
@@ -10,6 +10,11 @@
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
12
12
  * the old one keeps its own until that mount is disabled. There is no identity but the mount.
13
+ * ⛔ A MOVE ONTO A MOUNT WHOSE CONFIG EXISTS FAILS THE PLAN, like every other family's move onto an
14
+ * occupied path (rename-identity.ts). With no delete, nothing is lost to a swap, which MEASURED
15
+ * 2026-09-21 ended with each config where it was declared. But the new generation writes over
16
+ * the config it lands on, which changes who can log in on that mount: an issuer or key set
17
+ * someone else manages, silently replaced. Move through a mount with no config instead.
13
18
  */
14
19
  import { Resource } from 'alchemy';
15
20
  import { isResolved } from 'alchemy/Diff';
@@ -22,11 +27,12 @@ import {
22
27
  attributesOf,
23
28
  configPath,
24
29
  matches,
25
- mountOf,
26
30
  problems,
27
31
  wouldErase,
28
32
  writeBody,
29
33
  } from './jwt-config-form.ts';
34
+ import { declaredOr } from './rename.ts';
35
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
30
36
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
31
37
 
32
38
  export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
@@ -43,6 +49,16 @@ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig',
43
49
  defaultRemovalPolicy: 'retain',
44
50
  });
45
51
 
52
+ /** The identity is the mount alone; a trailing `/` is the same mount (`configPath` trims it). */
53
+ const IDENTITY: Identity<BaoJwtAuthConfigAttributes> = {
54
+ declared: (props) => {
55
+ const mount = declaredOr(props, 'mount', 'jwt');
56
+ return mount === undefined ? undefined : configPath({ mount });
57
+ },
58
+ family: 'Bao.JwtAuthConfig',
59
+ recorded: (output) => configPath(output),
60
+ };
61
+
46
62
  export const jwtConfigSpec = (
47
63
  props: BaoJwtAuthConfigProps,
48
64
  ): RoleSpec<BaoJwtAuthConfigAttributes> => ({
@@ -67,14 +83,19 @@ export const BaoJwtAuthConfigProvider = () =>
67
83
  return found?.attributes;
68
84
  }),
69
85
 
70
- diff: Effect.fn(function* ({ news, output }) {
71
- if (output === undefined || !isResolved(news)) return undefined;
72
- if (mountOf(news) !== output.mount) return { action: 'replace' } as const;
86
+ diff: Effect.fn(function* ({ news, olds, output }) {
87
+ // ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
88
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
89
+ if (output === undefined) return undefined;
90
+ if (move !== undefined) return { action: 'replace' } as const;
91
+ if (!isResolved(news)) return undefined;
73
92
  return { action: yield* planRole(jwtConfigSpec(news)) } as const;
74
93
  }),
75
94
 
76
95
  /** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
77
- reconcile: Effect.fn(function* ({ news }) {
96
+ reconcile: Effect.fn(function* ({ news, output }) {
97
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
98
+ yield* guardRename(IDENTITY, news, output);
78
99
  return yield* reconcileRole(jwtConfigSpec(news));
79
100
  }),
80
101
 
@@ -14,7 +14,8 @@
14
14
  *
15
15
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
16
16
  * path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
17
- * stays live, and still admits logins, until removed by hand.
17
+ * stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
18
+ * exists fails the plan (rename-identity.ts).
18
19
  * ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
19
20
  */
20
21
  import { Resource } from 'alchemy';
@@ -28,11 +29,11 @@ import {
28
29
  type BaoJwtRoleProps,
29
30
  attributesOf,
30
31
  matches,
31
- mountOf,
32
32
  problems,
33
33
  rolePath,
34
34
  writeBody,
35
35
  } from './jwt-role-form.ts';
36
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
36
37
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
37
38
 
38
39
  export type {
@@ -54,6 +55,18 @@ export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
54
55
  defaultRemovalPolicy: 'retain',
55
56
  });
56
57
 
58
+ /**
59
+ * The name folded, as the server keys it (`foldName`): `name` is a TypeLowerCaseString
60
+ * (builtin/credential/jwt/path_role.go:77), so the handler reads it lowercased before it ever builds
61
+ * `role/<name>` (:289). `App` → `app` is the same role, never a move; `problems` refuses the case.
62
+ */
63
+ const IDENTITY = roleIdentity<BaoJwtRoleAttributes>(
64
+ 'Bao.JwtRole',
65
+ (mount, name) => rolePath({ mount, name }),
66
+ 'jwt',
67
+ foldName,
68
+ );
69
+
57
70
  export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
58
71
  attributesOf: (live) => attributesOf(props, live),
59
72
  body: writeBody(props),
@@ -80,15 +93,18 @@ export const BaoJwtRoleProvider = () =>
80
93
  }),
81
94
 
82
95
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
83
- diff: Effect.fn(function* ({ news, output }) {
84
- if (output === undefined || !isResolved(news)) return undefined;
85
- if (mountOf(news) !== output.mount || news.name !== output.name) {
86
- return { action: 'replace' } as const;
87
- }
96
+ diff: Effect.fn(function* ({ news, olds, output }) {
97
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
98
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
99
+ if (output === undefined) return undefined;
100
+ if (move !== undefined) return { action: 'replace' } as const;
101
+ if (!isResolved(news)) return undefined;
88
102
  return { action: yield* planRole(jwtRoleSpec(news)) } as const;
89
103
  }),
90
104
 
91
- reconcile: Effect.fn(function* ({ news }) {
105
+ reconcile: Effect.fn(function* ({ news, output }) {
106
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
107
+ yield* guardRename(IDENTITY, news, output);
92
108
  return yield* reconcileRole(jwtRoleSpec(news));
93
109
  }),
94
110
 
@@ -9,7 +9,8 @@
9
9
  * and nothing else (path_role.go:239-256).
10
10
  *
11
11
  * ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
12
- * path cannot collide). Under the default `retain` the old role stays live until removed by hand.
12
+ * path cannot collide with the old). Under the default `retain` the old role stays live until
13
+ * removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
13
14
  */
14
15
  import { Resource } from 'alchemy';
15
16
  import { isResolved } from 'alchemy/Diff';
@@ -22,11 +23,11 @@ import {
22
23
  type BaoKubernetesRoleProps,
23
24
  attributesOf,
24
25
  matches,
25
- mountOf,
26
26
  problems,
27
27
  rolePath,
28
28
  writeBody,
29
29
  } from './kubernetes-role-form.ts';
30
+ import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
30
31
  import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
31
32
 
32
33
  export type {
@@ -47,6 +48,14 @@ export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole
47
48
  defaultRemovalPolicy: 'retain',
48
49
  });
49
50
 
51
+ /** The name folded, as the server stores it (`foldName`). */
52
+ const IDENTITY = roleIdentity<BaoKubernetesRoleAttributes>(
53
+ 'Bao.KubernetesRole',
54
+ (mount, name) => rolePath({ mount, name }),
55
+ 'kubernetes',
56
+ foldName,
57
+ );
58
+
50
59
  export const kubernetesRoleSpec = (
51
60
  props: BaoKubernetesRoleProps,
52
61
  ): RoleSpec<BaoKubernetesRoleAttributes> => ({
@@ -72,15 +81,18 @@ export const BaoKubernetesRoleProvider = () =>
72
81
  }),
73
82
 
74
83
  /** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
75
- diff: Effect.fn(function* ({ news, output }) {
76
- if (output === undefined || !isResolved(news)) return undefined;
77
- if (mountOf(news) !== output.mount || news.name !== output.name) {
78
- return { action: 'replace' } as const;
79
- }
84
+ diff: Effect.fn(function* ({ news, olds, output }) {
85
+ // ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
86
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
87
+ if (output === undefined) return undefined;
88
+ if (move !== undefined) return { action: 'replace' } as const;
89
+ if (!isResolved(news)) return undefined;
80
90
  return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
81
91
  }),
82
92
 
83
- reconcile: Effect.fn(function* ({ news }) {
93
+ reconcile: Effect.fn(function* ({ news, output }) {
94
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
95
+ yield* guardRename(IDENTITY, news, output);
84
96
  return yield* reconcileRole(kubernetesRoleSpec(news));
85
97
  }),
86
98
 
@@ -19,9 +19,12 @@
19
19
  * default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
20
20
  * live — which is what the server would do anyway.
21
21
  *
22
- * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first (names are per
23
- * namespace; the new one cannot collide). Under `retain` BOTH enforcements then apply — fail
24
- * closed. Under `destroy` the old one's delete is refused, so the deploy stops after creating.
22
+ * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
23
+ * enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
24
+ * deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
25
+ * (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
26
+ * enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
27
+ * other, then stopped on the two refused deletes.
25
28
  */
26
29
  import { Resource } from 'alchemy';
27
30
  import { isResolved } from 'alchemy/Diff';
@@ -38,7 +41,8 @@ import {
38
41
  problems,
39
42
  writeBody,
40
43
  } from './mfa-enforcement-form.ts';
41
- import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
44
+ import { authAccessors, enforcementPath, readEnforcement, writeEnforcement } from './mfa-wire.ts';
45
+ import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
42
46
 
43
47
  export type {
44
48
  BaoMfaLoginEnforcementAttributes,
@@ -59,6 +63,12 @@ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaL
59
63
 
60
64
  type Env = HttpClient.HttpClient;
61
65
 
66
+ /** Exact: the identity store indexes enforcements by (namespace, name) as given. */
67
+ const IDENTITY = nameIdentity<BaoMfaLoginEnforcementAttributes>(
68
+ 'Bao.MfaLoginEnforcement',
69
+ enforcementPath,
70
+ );
71
+
62
72
  const refuse = (name: string, message: string): Effect.Effect<never> =>
63
73
  Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
64
74
 
@@ -119,13 +129,18 @@ export const BaoMfaLoginEnforcementProvider = () =>
119
129
  }),
120
130
 
121
131
  /** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
122
- diff: Effect.fn(function* ({ news, output }) {
123
- if (output === undefined || !isResolved(news)) return undefined;
124
- if (news.name !== output.name) return { action: 'replace' } as const;
132
+ diff: Effect.fn(function* ({ news, olds, output }) {
133
+ // ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
134
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
135
+ if (output === undefined) return undefined;
136
+ if (move !== undefined) return { action: 'replace' } as const;
137
+ if (!isResolved(news)) return undefined;
125
138
  return { action: yield* planEnforcement(news) } as const;
126
139
  }),
127
140
 
128
- reconcile: Effect.fn(function* ({ news }) {
141
+ reconcile: Effect.fn(function* ({ news, output }) {
142
+ // ⛔ An `update` across a rename the diff could not see — refused before any write.
143
+ yield* guardRename(IDENTITY, news, output);
129
144
  return yield* reconcileEnforcement(news);
130
145
  }),
131
146
 
@@ -31,6 +31,7 @@ import {
31
31
  writeBody,
32
32
  } from './mfa-totp-form.ts';
33
33
  import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
34
+ import { declaredString } from './rename.ts';
34
35
 
35
36
  export type {
36
37
  BaoMfaTotpMethodAttributes,
@@ -50,8 +51,8 @@ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod',
50
51
  defaultRemovalPolicy: 'retain',
51
52
  });
52
53
 
53
- const refuse = (props: BaoMfaTotpMethodProps, message: string): Effect.Effect<never> =>
54
- Effect.die(new Error(`Bao.MfaTotpMethod ${props.name}: ${message}`));
54
+ const refuse = (name: string, message: string): Effect.Effect<never> =>
55
+ Effect.die(new Error(`Bao.MfaTotpMethod ${name}: ${message}`));
55
56
 
56
57
  export const readTotp = (
57
58
  props: BaoMfaTotpMethodProps,
@@ -75,13 +76,13 @@ export const reconcileTotp = (
75
76
  ): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
76
77
  Effect.gen(function* () {
77
78
  const bad = problems(props);
78
- if (bad.length > 0) return yield* refuse(props, bad.join('; '));
79
+ if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
79
80
  const live = yield* readTotp(props);
80
81
  if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
81
82
  const after = yield* readTotp(props);
82
83
  if (after === undefined)
83
- return yield* refuse(props, 'the write succeeded but no method has that name.');
84
- if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
84
+ return yield* refuse(props.name, 'the write succeeded but no method has that name.');
85
+ if (!matches(after, props)) return yield* refuse(props.name, 'the method read back different.');
85
86
  return after;
86
87
  });
87
88
 
@@ -97,14 +98,26 @@ export const BaoMfaTotpMethodProvider = () =>
97
98
  return yield* readTotp(olds);
98
99
  }),
99
100
 
101
+ /**
102
+ * ⛔ THE NAME IS JUDGED BEFORE `isResolved(news)`. Until 2026-09-21 it came after, so a
103
+ * rename landing with a pending `issuer` (or any other Output) planned `update`, and the
104
+ * upsert by name wrote a SECOND method: the header's stranding, reached through a green
105
+ * plan. MEASURED through the engine (rename-families.test.ts).
106
+ */
100
107
  diff: Effect.fn(function* ({ news, output }) {
108
+ const name = declaredString(news, 'name');
109
+ if (output !== undefined && name !== undefined) {
110
+ const rename = renameProblem(output.name, name);
111
+ if (rename !== undefined) return yield* refuse(name, rename);
112
+ }
101
113
  if (output === undefined || !isResolved(news)) return undefined;
102
- const rename = renameProblem(output.name, news.name);
103
- if (rename !== undefined) return yield* refuse(news, rename);
104
114
  return { action: yield* planTotp(news) } as const;
105
115
  }),
106
116
 
107
- reconcile: Effect.fn(function* ({ news }) {
117
+ /** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
118
+ reconcile: Effect.fn(function* ({ news, output }) {
119
+ const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
120
+ if (rename !== undefined) return yield* refuse(news.name, rename);
108
121
  return yield* reconcileTotp(news);
109
122
  }),
110
123
 
@@ -44,7 +44,6 @@ import * as Provider from 'alchemy/Provider';
44
44
  import * as Effect from 'effect/Effect';
45
45
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
46
46
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
47
- import { mountPath } from './mount-form.ts';
48
47
  import {
49
48
  type BaoPkiRoleAttributes,
50
49
  type BaoPkiRoleProps,
@@ -54,6 +53,7 @@ import {
54
53
  rolePath,
55
54
  writeBody,
56
55
  } from './pki-role-form.ts';
56
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
57
57
 
58
58
  export type { BaoPkiRoleAttributes, BaoPkiRoleProps };
59
59
 
@@ -69,6 +69,13 @@ export const BaoPkiRole = Resource<BaoPkiRole>('Bao.PkiRole', {
69
69
  defaultRemovalPolicy: 'retain',
70
70
  });
71
71
 
72
+ /** Exact: the engine stores `role/<name>` verbatim (builtin/logical/pki/path_roles.go:963). */
73
+ const IDENTITY = roleIdentity<BaoPkiRoleAttributes>(
74
+ 'Bao.PkiRole',
75
+ (mount, name) => rolePath({ mount, name }),
76
+ 'pki',
77
+ );
78
+
72
79
  const readRole = (props: BaoPkiRoleProps) =>
73
80
  Effect.gen(function* () {
74
81
  const live = yield* baoRead(rolePath(props));
@@ -100,18 +107,21 @@ export const BaoPkiRoleProvider = () =>
100
107
  * fields is also what stops a field being added to props and quietly forgotten in
101
108
  * the comparison.
102
109
  */
103
- diff: Effect.fn(function* ({ news, output }) {
104
- if (output === undefined || !isResolved(news)) return undefined;
110
+ diff: Effect.fn(function* ({ news, olds, output }) {
105
111
  /**
106
112
  * ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
107
113
  * engine is a different object under a different CA, never an in-place edit —
108
- * `replace`, so Alchemy creates the new one and retires the old.
114
+ * `replace`, so Alchemy creates the new one and retires the old. Judged before
115
+ * `isResolved(news)`, and ⛔ a move onto a role that exists fails the plan
116
+ * (rename-identity.ts).
109
117
  * ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
110
118
  * so a new `name` read nothing at the new path, planned `update`, wrote the new role,
111
119
  * and left the old one issuing certificates under no state record at all.
112
120
  */
113
- if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
114
- return { action: 'replace' } as const;
121
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
122
+ if (output === undefined) return undefined;
123
+ if (move !== undefined) return { action: 'replace' } as const;
124
+ if (!isResolved(news)) return undefined;
115
125
  /**
116
126
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
117
127
  * allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
@@ -127,8 +137,10 @@ export const BaoPkiRoleProvider = () =>
127
137
  : ({ action: 'update' } as const);
128
138
  }),
129
139
 
130
- reconcile: Effect.fn(function* ({ news }) {
140
+ reconcile: Effect.fn(function* ({ news, output }) {
131
141
  const path = rolePath(news);
142
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
143
+ yield* guardRename(IDENTITY, news, output);
132
144
  /**
133
145
  * ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
134
146
  * false on every live role, so a role with no allowed domains can issue NOTHING —
@@ -24,9 +24,18 @@ import { isResolved } from 'alchemy/Diff';
24
24
  import * as Provider from 'alchemy/Provider';
25
25
  import * as Effect from 'effect/Effect';
26
26
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
27
- import { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
27
+ import {
28
+ type BaoPluginAttributes,
29
+ type BaoPluginProps,
30
+ type BaoPluginType,
31
+ matches,
32
+ resolve,
33
+ versionedPath,
34
+ } from './plugin-form.ts';
28
35
  import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
29
36
  import { deregisterPlugin } from './plugin-wire.ts';
37
+ import { declaredOr, declaredString } from './rename.ts';
38
+ import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
30
39
 
31
40
  export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
32
41
 
@@ -42,6 +51,24 @@ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
42
51
  defaultRemovalPolicy: 'retain',
43
52
  });
44
53
 
54
+ /**
55
+ * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so the identity is the versioned path a read and a
56
+ * delete address. Exact: the catalog stores `<type>/<name>/<version>` as given, and the version
57
+ * is already canonical (`problems`). ⚠️ An unversioned name that is a builtin reads back as
58
+ * present (plugin-wire.ts), so a move onto one fails the plan, as reconcile would refuse anyway.
59
+ */
60
+ const IDENTITY: Identity<BaoPluginAttributes> = {
61
+ declared: (props) => {
62
+ const type = declaredString(props, 'type') as BaoPluginType | undefined;
63
+ const name = declaredString(props, 'name');
64
+ const version = declaredOr(props, 'version', '');
65
+ if (type === undefined || name === undefined || version === undefined) return undefined;
66
+ return versionedPath(type, name, version);
67
+ },
68
+ family: 'Bao.Plugin',
69
+ recorded: (output) => versionedPath(output.type, output.name, output.version),
70
+ };
71
+
45
72
  export const BaoPluginProvider = () =>
46
73
  Provider.effect(
47
74
  BaoPlugin,
@@ -60,21 +87,18 @@ export const BaoPluginProvider = () =>
60
87
  }),
61
88
 
62
89
  /** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
63
- diff: Effect.fn(function* ({ news, output }) {
64
- if (output === undefined || !isResolved(news)) return undefined;
65
- const form = resolve(news);
90
+ diff: Effect.fn(function* ({ news, olds, output }) {
66
91
  /**
67
- * ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
68
- * not an edit. Writing the new key would leave the old registration in place under a
69
- * state record that no longer names it.
92
+ * ⛔ A NEW NAME, TYPE OR VERSION IS A DIFFERENT ENTRY, not an edit: `replace`, judged
93
+ * before `isResolved(news)`. Writing the new key in place would leave the old
94
+ * registration under a state record that no longer names it. ⛔ A move onto an entry
95
+ * that is already registered fails the plan (rename-identity.ts).
70
96
  */
71
- if (
72
- output.name !== form.name ||
73
- output.type !== form.type ||
74
- output.version !== form.version
75
- ) {
76
- return { action: 'replace' } as const;
77
- }
97
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
98
+ if (output === undefined) return undefined;
99
+ if (move !== undefined) return { action: 'replace' } as const;
100
+ if (!isResolved(news)) return undefined;
101
+ const form = resolve(news);
78
102
  const found = yield* readEntry(form);
79
103
  if (found === undefined) return { action: 'update' } as const;
80
104
  return matches(found.attributes, form)
@@ -83,7 +107,9 @@ export const BaoPluginProvider = () =>
83
107
  }),
84
108
 
85
109
  /** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
86
- reconcile: Effect.fn(function* ({ news }) {
110
+ reconcile: Effect.fn(function* ({ news, output }) {
111
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
112
+ yield* guardRename(IDENTITY, news, output);
87
113
  return yield* reconcilePlugin(news);
88
114
  }),
89
115