@homeflare/alchemy 0.7.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 (137) hide show
  1. package/README.md +26 -37
  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 +71 -13
  11. package/dist/caddy/index.js.map +9 -9
  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-sudo.d.ts +50 -0
  15. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  16. package/dist/launchd/host-effect.d.ts +18 -9
  17. package/dist/launchd/host-effect.d.ts.map +1 -1
  18. package/dist/launchd/host-file-form.d.ts +5 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  21. package/dist/launchd/index.d.ts +2 -0
  22. package/dist/launchd/index.d.ts.map +1 -1
  23. package/dist/launchd/index.js +342 -8
  24. package/dist/launchd/index.js.map +15 -11
  25. package/dist/launchd/job-preflight.d.ts.map +1 -1
  26. package/dist/launchd/job-validate.d.ts +2 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -1
  28. package/dist/launchd/local-runner.d.ts +2 -0
  29. package/dist/launchd/local-runner.d.ts.map +1 -1
  30. package/dist/launchd/providers.d.ts +2 -1
  31. package/dist/launchd/providers.d.ts.map +1 -1
  32. package/dist/launchd/runner.d.ts +7 -5
  33. package/dist/launchd/runner.d.ts.map +1 -1
  34. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  35. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  36. package/dist/launchd/sudo-guard.d.ts +56 -0
  37. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  38. package/dist/launchd/sudo-runner.d.ts +60 -0
  39. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  40. package/dist/launchd/sudo-stage.d.ts +7 -0
  41. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  42. package/dist/openbao/auth-role-form.d.ts +0 -9
  43. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-role.d.ts +3 -2
  45. package/dist/openbao/auth-role.d.ts.map +1 -1
  46. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  47. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  48. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  49. package/dist/openbao/fake-engines.d.ts +8 -2
  50. package/dist/openbao/fake-engines.d.ts.map +1 -1
  51. package/dist/openbao/fake-families.d.ts +49 -0
  52. package/dist/openbao/fake-families.d.ts.map +1 -0
  53. package/dist/openbao/host-approles.d.ts.map +1 -1
  54. package/dist/openbao/index.js +359 -280
  55. package/dist/openbao/index.js.map +21 -19
  56. package/dist/openbao/jwt-config.d.ts +5 -0
  57. package/dist/openbao/jwt-config.d.ts.map +1 -1
  58. package/dist/openbao/jwt-role.d.ts +2 -1
  59. package/dist/openbao/jwt-role.d.ts.map +1 -1
  60. package/dist/openbao/kubernetes-role.d.ts +2 -1
  61. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  62. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  63. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  64. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  65. package/dist/openbao/pki-role.d.ts.map +1 -1
  66. package/dist/openbao/plugin.d.ts.map +1 -1
  67. package/dist/openbao/policy.d.ts.map +1 -1
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename-identity.d.ts +69 -0
  70. package/dist/openbao/rename-identity.d.ts.map +1 -0
  71. package/dist/openbao/rename.d.ts +13 -12
  72. package/dist/openbao/rename.d.ts.map +1 -1
  73. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  74. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  75. package/dist/openbao/ssh-role.d.ts.map +1 -1
  76. package/docs/caddy-admin.md +24 -0
  77. package/docs/caddy.md +73 -54
  78. package/docs/launchd-sudo.md +122 -0
  79. package/docs/launchd.md +7 -4
  80. package/docs/peers.md +39 -0
  81. package/package.json +1 -1
  82. package/src/caddy/adopt-scope.test.ts +82 -0
  83. package/src/caddy/config-adopt.test.ts +235 -0
  84. package/src/caddy/config-lifecycle.test.ts +31 -53
  85. package/src/caddy/config-lifecycle.ts +98 -7
  86. package/src/caddy/config.ts +48 -11
  87. package/src/caddy/fake-caddy.ts +19 -0
  88. package/src/caddy/index.ts +2 -1
  89. package/src/caddy/providers.test.ts +8 -23
  90. package/src/caddy/with-file.ts +7 -3
  91. package/src/launchd/fake-sudo.ts +136 -0
  92. package/src/launchd/host-effect.ts +33 -1
  93. package/src/launchd/host-file-form.ts +10 -2
  94. package/src/launchd/host-file-lifecycle.ts +2 -1
  95. package/src/launchd/index.ts +2 -0
  96. package/src/launchd/job-preflight.ts +3 -2
  97. package/src/launchd/job-validate.ts +1 -1
  98. package/src/launchd/local-runner.ts +6 -3
  99. package/src/launchd/providers.test.ts +3 -0
  100. package/src/launchd/providers.ts +2 -1
  101. package/src/launchd/runner.ts +7 -5
  102. package/src/launchd/sudo-allowlist.test.ts +191 -0
  103. package/src/launchd/sudo-allowlist.ts +213 -0
  104. package/src/launchd/sudo-guard.test.ts +100 -0
  105. package/src/launchd/sudo-guard.ts +151 -0
  106. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  107. package/src/launchd/sudo-runner-files.test.ts +240 -0
  108. package/src/launchd/sudo-runner.test.ts +126 -0
  109. package/src/launchd/sudo-runner.ts +235 -0
  110. package/src/launchd/sudo-stage.ts +28 -0
  111. package/src/openbao/README.md +8 -2
  112. package/src/openbao/REPLACE.md +65 -37
  113. package/src/openbao/auth-role-form.test.ts +2 -16
  114. package/src/openbao/auth-role-form.ts +0 -11
  115. package/src/openbao/auth-role.ts +35 -14
  116. package/src/openbao/cloudflare-role.ts +6 -14
  117. package/src/openbao/fake-engines-roles.ts +114 -0
  118. package/src/openbao/fake-engines.ts +18 -11
  119. package/src/openbao/fake-families.ts +182 -0
  120. package/src/openbao/host-approles.test.ts +15 -2
  121. package/src/openbao/host-approles.ts +19 -4
  122. package/src/openbao/jwt-config.ts +26 -5
  123. package/src/openbao/jwt-role.ts +24 -8
  124. package/src/openbao/kubernetes-role.ts +20 -8
  125. package/src/openbao/mfa-enforcement.ts +23 -8
  126. package/src/openbao/mfa-totp.ts +21 -8
  127. package/src/openbao/pki-role.ts +19 -7
  128. package/src/openbao/plugin.ts +41 -15
  129. package/src/openbao/policy.ts +7 -7
  130. package/src/openbao/proxmox-role.ts +7 -16
  131. package/src/openbao/rename-adoption.test.ts +82 -0
  132. package/src/openbao/rename-families.test.ts +122 -0
  133. package/src/openbao/rename-identity.test.ts +129 -0
  134. package/src/openbao/rename-identity.ts +117 -0
  135. package/src/openbao/rename.ts +19 -15
  136. package/src/openbao/ssh-role-erase.ts +50 -0
  137. package/src/openbao/ssh-role.ts +16 -54
@@ -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 * as Provider from 'alchemy/Provider';
@@ -1 +1 @@
1
- {"version":3,"file":"jwt-config.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAQ3B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE9F,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAEH,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,QAAQ,CAAC,0BAA0B,CAQpC,CAAC;AAEH,eAAO,MAAM,wBAAwB,uGA2BlC,CAAC"}
1
+ {"version":3,"file":"jwt-config.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAO3B,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE9F,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAYH,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,QAAQ,CAAC,0BAA0B,CAQpC,CAAC;AAEH,eAAO,MAAM,wBAAwB,uGAgClC,CAAC"}
@@ -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';
@@ -1 +1 @@
1
- {"version":3,"file":"jwt-role.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAOrB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,cAAc,GACf,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAEH,eAAO,MAAM,WAAW,UAAW,eAAe,KAAG,QAAQ,CAAC,oBAAoB,CAOhF,CAAC;AAEH,eAAO,MAAM,kBAAkB,iGAoC5B,CAAC"}
1
+ {"version":3,"file":"jwt-role.d.ts","sourceRoot":"","sources":["../../src/openbao/jwt-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAMrB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,cAAc,GACf,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAcH,eAAO,MAAM,WAAW,UAAW,eAAe,KAAG,QAAQ,CAAC,oBAAoB,CAOhF,CAAC;AAEH,eAAO,MAAM,kBAAkB,iGAuC5B,CAAC"}
@@ -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 * as Provider from 'alchemy/Provider';
@@ -1 +1 @@
1
- {"version":3,"file":"kubernetes-role.d.ts","sourceRoot":"","sources":["../../src/openbao/kubernetes-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,2BAA2B,EAChC,KAAK,sBAAsB,EAO5B,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,wBAAwB,EACxB,2BAA2B,EAC3B,sBAAsB,GACvB,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,iBAAkB,SAAQ,QAAQ,CACjD,oBAAoB,EACpB,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,iBAAiB,oDAE5B,CAAC;AAEH,eAAO,MAAM,kBAAkB,UACtB,sBAAsB,KAC5B,QAAQ,CAAC,2BAA2B,CAOrC,CAAC;AAEH,eAAO,MAAM,yBAAyB,wGAiCnC,CAAC"}
1
+ {"version":3,"file":"kubernetes-role.d.ts","sourceRoot":"","sources":["../../src/openbao/kubernetes-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,2BAA2B,EAChC,KAAK,sBAAsB,EAM5B,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAE,KAAK,QAAQ,EAAuC,MAAM,qBAAqB,CAAC;AAEzF,YAAY,EACV,wBAAwB,EACxB,2BAA2B,EAC3B,sBAAsB,GACvB,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,iBAAkB,SAAQ,QAAQ,CACjD,oBAAoB,EACpB,sBAAsB,EACtB,2BAA2B,EAC3B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,iBAAiB,oDAE5B,CAAC;AAUH,eAAO,MAAM,kBAAkB,UACtB,sBAAsB,KAC5B,QAAQ,CAAC,2BAA2B,CAOrC,CAAC;AAEH,eAAO,MAAM,yBAAyB,wGAoCnC,CAAC"}
@@ -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 * as Provider from 'alchemy/Provider';
@@ -1 +1 @@
1
- {"version":3,"file":"mfa-enforcement.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-enforcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,gCAAgC,EACrC,KAAK,2BAA2B,EAMjC,MAAM,2BAA2B,CAAC;AAGnC,YAAY,EACV,gCAAgC,EAChC,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,sBAAuB,SAAQ,QAAQ,CACtD,yBAAyB,EACzB,2BAA2B,EAC3B,gCAAgC,EAChC,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,sBAAsB,yDAEjC,CAAC;AAEH,KAAK,GAAG,GAAG,UAAU,CAAC,UAAU,CAAC;AAmBjC,eAAO,MAAM,eAAe,UACnB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,CAM7C,CAAC;AAEL,8DAA8D;AAC9D,eAAO,MAAM,oBAAoB,UACxB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,gCAAgC,EAAE,QAAQ,EAAE,GAAG,CAe5D,CAAC;AAEL,eAAO,MAAM,8BAA8B,6GAkCxC,CAAC"}
1
+ {"version":3,"file":"mfa-enforcement.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-enforcement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,gCAAgC,EACrC,KAAK,2BAA2B,EAMjC,MAAM,2BAA2B,CAAC;AAInC,YAAY,EACV,gCAAgC,EAChC,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,sBAAuB,SAAQ,QAAQ,CACtD,yBAAyB,EACzB,2BAA2B,EAC3B,gCAAgC,EAChC,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,sBAAsB,yDAEjC,CAAC;AAEH,KAAK,GAAG,GAAG,UAAU,CAAC,UAAU,CAAC;AAyBjC,eAAO,MAAM,eAAe,UACnB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,CAM7C,CAAC;AAEL,8DAA8D;AAC9D,eAAO,MAAM,oBAAoB,UACxB,2BAA2B,KACjC,MAAM,CAAC,MAAM,CAAC,gCAAgC,EAAE,QAAQ,EAAE,GAAG,CAe5D,CAAC;AAEL,eAAO,MAAM,8BAA8B,6GAuCxC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"mfa-totp.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-totp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAM3B,MAAM,oBAAoB,CAAC;AAG5B,YAAY,EACV,0BAA0B,EAC1B,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAKH,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAGrF,CAAC;AAEJ,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAK/D,CAAC;AAEL,uEAAuE;AACvE,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAWxE,CAAC;AAEL,eAAO,MAAM,wBAAwB,uGA8BlC,CAAC"}
1
+ {"version":3,"file":"mfa-totp.d.ts","sourceRoot":"","sources":["../../src/openbao/mfa-totp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,qBAAqB,EAM3B,MAAM,oBAAoB,CAAC;AAI5B,YAAY,EACV,0BAA0B,EAC1B,qBAAqB,EACrB,gBAAgB,GACjB,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,gBAAiB,SAAQ,QAAQ,CAChD,mBAAmB,EACnB,qBAAqB,EACrB,0BAA0B,EAC1B,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,gBAAgB,mDAE3B,CAAC;AAKH,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,GAAG,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAGrF,CAAC;AAEJ,eAAO,MAAM,QAAQ,UACZ,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAK/D,CAAC;AAEL,uEAAuE;AACvE,eAAO,MAAM,aAAa,UACjB,qBAAqB,KAC3B,MAAM,CAAC,MAAM,CAAC,0BAA0B,EAAE,QAAQ,EAAE,UAAU,CAAC,UAAU,CAWxE,CAAC;AAEL,eAAO,MAAM,wBAAwB,uGA0ClC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"pki-role.d.ts","sourceRoot":"","sources":["../../src/openbao/pki-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAGnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAMrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AASH,eAAO,MAAM,kBAAkB,iGA4G5B,CAAC"}
1
+ {"version":3,"file":"pki-role.d.ts","sourceRoot":"","sources":["../../src/openbao/pki-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EACzB,KAAK,eAAe,EAMrB,MAAM,oBAAoB,CAAC;AAG5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAgBH,eAAO,MAAM,kBAAkB,iGAiH5B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,cAAc,EAAoB,MAAM,kBAAkB,CAAC;AAInG,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE3F,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAEH,eAAO,MAAM,iBAAiB,gGAuD3B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/openbao/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AACnE,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,cAAc,EAKpB,MAAM,kBAAkB,CAAC;AAM1B,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE3F,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAoBH,eAAO,MAAM,iBAAiB,gGAsD3B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,cAAc;IAC7B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAC1D;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAYH,eAAO,MAAM,iBAAiB,oIA2H3B,CAAC"}
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAOnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,cAAc;IAC7B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAC1D;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAeH,eAAO,MAAM,iBAAiB,oIAuH3B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;AAYhC,YAAY,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAE9D,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,qGAkKhC,CAAC"}
1
+ {"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;AAKhC,YAAY,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAE9D,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAKH,eAAO,MAAM,sBAAsB,qGA6JhC,CAAC"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The rename guard every Bao.* family whose identity is a name, a mount, or a catalog key shares:
3
+ * one `Identity` per family, and two steps built from rename.ts, `judgeRename` at the top of `diff`
4
+ * and `guardRename` at the top of `reconcile`. REPLACE.md has the audit behind both.
5
+ *
6
+ * ★ ONE DESCRIPTION OF "WHICH OBJECT", NOT TWELVE COPIES OF THE CHECK. The rename-safety fix went
7
+ * in family by family, and the families it skipped were the ones that kept the bug: nine of them
8
+ * judged the identity only after `isResolved(news)` and never asked whether the new path was
9
+ * taken. 🔴 MEASURED 2026-09-21 through the engine (rename-families.test.ts): two Bao.AuthRole,
10
+ * Bao.PkiRole, Bao.JwtRole, Bao.KubernetesRole, Bao.SshRole or Bao.Plugin resources that swapped
11
+ * names under `RemovalPolicy.destroy()` both planned `replace`, each wrote over the other, and
12
+ * both old deletes then removed what had just been written: a green deploy, both objects gone.
13
+ * With any other prop still an Output, all nine planned `update` and left the old object live
14
+ * under no state record; for Bao.MfaTotpMethod that wrote a second method and stranded every
15
+ * enrolment.
16
+ */
17
+ import * as Effect from 'effect/Effect';
18
+ /** How one family names the object it manages — the thing a rename changes. */
19
+ export interface Identity<A> {
20
+ /** `Bao.JwtRole` etc., for messages. */
21
+ readonly family: string;
22
+ /** The identity a set of props declares; undefined while any part of it is still an Output. */
23
+ readonly declared: (props: unknown) => string | undefined;
24
+ /** The identity a generation's attributes record. */
25
+ readonly recorded: (output: A) => string;
26
+ /** The API path an identity is read at, for the occupied check. Default: the identity. */
27
+ readonly pathOf?: ((identity: string) => string) | undefined;
28
+ /** How the server keys it (`policyKey`). Default: exact. */
29
+ readonly key?: ((identity: string) => string) | undefined;
30
+ }
31
+ /** A family keyed by `name` alone, read at `pathOf(name)`. */
32
+ export declare const nameIdentity: <A extends {
33
+ readonly name: string;
34
+ }>(family: string, pathOf: (name: string) => string, key?: (name: string) => string) => Identity<A>;
35
+ /**
36
+ * A role at `rolePath(mount, name)`; the identity is that path. `defaultMount` is what an absent
37
+ * `mount` prop means — undefined when `mount` is required.
38
+ */
39
+ export declare const roleIdentity: <A extends {
40
+ readonly mount: string;
41
+ readonly name: string;
42
+ }>(family: string, rolePath: (mount: string, name: string) => string, defaultMount?: string, key?: (path: string) => string) => Identity<A>;
43
+ /**
44
+ * ⚠️ THE LAST SEGMENT LOWERCASED: a role store that folds the name and not the mount. openbao v2.6.2
45
+ * AppRole (builtin/credential/approle/path_role.go:1485, :1537) and Kubernetes
46
+ * (builtin/credential/kubernetes/backend.go:362, path_role.go:396) both store `role/<lowercased
47
+ * name>`, and JWT reads its `name` as a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77),
48
+ * while the router folds no case in a mount path. So `Host` → `host` is the same role, never a
49
+ * replace that would delete, under `destroy`, the role it had just written.
50
+ */
51
+ export declare const foldName: (path: string) => string;
52
+ /**
53
+ * `diff`'s first step, before `isResolved(news)`: the move from the identity this resource last
54
+ * wrote or tried to the declared one, or undefined when both name the same object or either is not
55
+ * known yet. ⛔ Fails the plan when the new identity is already taken (`judgeMove`).
56
+ *
57
+ * ★ `olds` STANDS IN FOR `output` WHILE A CREATE OR A REPLACEMENT IS UNFINISHED, which is what
58
+ * catches reverting a move whose new generation failed (rename.ts, `judgeMove`).
59
+ */
60
+ export declare const judgeRename: <A>(identity: Identity<A>, olds: unknown, news: unknown, output: A | undefined) => Effect.Effect<{
61
+ from: string;
62
+ to: string;
63
+ } | undefined, import("./bao-status.ts").BaoError, import("effect/unstable/http/HttpClient").HttpClient>;
64
+ /**
65
+ * `reconcile`'s first step: an `update` whose prior attributes name another object dies before any
66
+ * read or write (`refuseMovedUpdate`). Only reached when the diff could not see the new identity.
67
+ */
68
+ export declare const guardRename: <A>(identity: Identity<A>, news: unknown, output: A | undefined) => Effect.Effect<void>;
69
+ //# sourceMappingURL=rename-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rename-identity.d.ts","sourceRoot":"","sources":["../../src/openbao/rename-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AASxC,+EAA+E;AAC/E,MAAM,WAAW,QAAQ,CAAC,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,+FAA+F;IAC/F,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1D,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,MAAM,CAAC;IACzC,0FAA0F;IAC1F,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,4DAA4D;IAC5D,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC,GAAG,SAAS,CAAC;CAC3D;AAED,8DAA8D;AAC9D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,UACtD,MAAM,UACN,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,QAC1B,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,KAC7B,QAAQ,CAAC,CAAC,CAMX,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,UAC9E,MAAM,YACJ,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,iBAClC,MAAM,QACf,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,KAC7B,QAAQ,CAAC,CAAC,CAKX,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,SAAU,MAAM,KAAG,MACc,CAAC;AAEvD;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,YACjB,QAAQ,CAAC,CAAC,CAAC,QACf,OAAO,QACP,OAAO,UACL,CAAC,GAAG,SAAS;;;wGAQpB,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,WAAW,GAAI,CAAC,YACjB,QAAQ,CAAC,CAAC,CAAC,QACf,OAAO,UACL,CAAC,GAAG,SAAS,KACpB,MAAM,CAAC,MAAM,CAAC,IAAI,CAOpB,CAAC"}
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Rename safety for the families whose identity is a name, or a mount and a name: Bao.Policy,
3
- * Bao.CloudflareRole and Bao.ProxmoxRole. REPLACE.md has the audit and what each answer does.
2
+ * Rename safety for the families whose identity is a name, a mount and a name, or a catalog key:
3
+ * the primitives. rename-identity.ts builds each family's diff and reconcile step from them, and
4
+ * REPLACE.md has the audit and what each answer does.
4
5
  *
5
6
  * ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
6
7
  * (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
@@ -32,8 +33,16 @@ import * as Effect from 'effect/Effect';
32
33
  export declare const declaredString: (news: unknown, key: string) => string | undefined;
33
34
  /** True while an optional prop is declared but is not yet a value: an Output of a changing upstream. */
34
35
  export declare const isPendingProp: (news: unknown, key: string) => boolean;
35
- /** A role's API path from a diff's `news`, once both `mount` and `name` are resolved. */
36
- export declare const declaredRolePath: (news: unknown, rolePath: (mount: string, name: string) => string) => string | undefined;
36
+ /**
37
+ * An optional string prop: `fallback` when it is absent, undefined while it is still an Output.
38
+ * ⚠️ Not `declaredString(…) ?? fallback`, which would read a pending mount as the default mount.
39
+ */
40
+ export declare const declaredOr: (news: unknown, key: string, fallback: string) => string | undefined;
41
+ /**
42
+ * A role's API path from a diff's `news`, once both `mount` and `name` are resolved. `defaultMount`
43
+ * is what an absent `mount` means; without one, an absent mount is unknown.
44
+ */
45
+ export declare const declaredRolePath: (news: unknown, rolePath: (mount: string, name: string) => string, defaultMount?: string) => string | undefined;
37
46
  /**
38
47
  * How OpenBao keys an ACL policy: trimmed and lowercased. openbao v2.6.2
39
48
  * vault/policy/policy_store.go:698-700 (`sanitizeName`), applied by SetPolicy (:268), GetPolicy
@@ -57,14 +66,6 @@ export declare const policyKey: (name: string) => string;
57
66
  * `refuseMovedUpdate` stops reconcile before it writes anything.
58
67
  */
59
68
  export declare const isMoved: (stored: string, declared: string | undefined, key?: (identity: string) => string) => boolean | undefined;
60
- /**
61
- * The role path a resource last wrote, or last tried to: its attributes once a write finished, else
62
- * the props of the create or replacement that did not (`output` is undefined for both).
63
- */
64
- export declare const triedRolePath: (output: {
65
- readonly mount: string;
66
- readonly name: string;
67
- } | undefined, olds: unknown, rolePath: (mount: string, name: string) => string) => string | undefined;
68
69
  /**
69
70
  * The diff's first question, asked before `isResolved(news)`: the move from the identity this
70
71
  * resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
@@ -1 +1 @@
1
- {"version":3,"file":"rename.d.ts","sourceRoot":"","sources":["../../src/openbao/rename.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,SAAU,OAAO,OAAO,MAAM,KAAG,MAAM,GAAG,SAIpE,CAAC;AAEF,wGAAwG;AACxG,eAAO,MAAM,aAAa,SAAU,OAAO,OAAO,MAAM,KAAG,OAI1D,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,gBAAgB,SACrB,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,KAChD,MAAM,GAAG,SAIX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,SAAU,MAAM,KAAG,MAAmC,CAAC;AAE7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,WACV,MAAM,YACJ,MAAM,GAAG,SAAS,QACvB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,KAChC,OAAO,GAAG,SAAiF,CAAC;AAE/F;;;GAGG;AACH,eAAO,MAAM,aAAa,WAChB;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,QAC/D,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,KAChD,MAAM,GAAG,SACmF,CAAC;AAEhG;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS,WACZ,MAAM,SACP,MAAM,GAAG,SAAS,YACf,MAAM,GAAG,SAAS,UACpB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,QAC/B,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM;;;wGAgB/B,CAAC;AAEL;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,WAAY,MAAM,QAAQ,MAAM,MAAM,MAAM,uCAOvE,CAAC"}
1
+ {"version":3,"file":"rename.d.ts","sourceRoot":"","sources":["../../src/openbao/rename.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,SAAU,OAAO,OAAO,MAAM,KAAG,MAAM,GAAG,SAIpE,CAAC;AAEF,wGAAwG;AACxG,eAAO,MAAM,aAAa,SAAU,OAAO,OAAO,MAAM,KAAG,OAI1D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,UAAU,SAAU,OAAO,OAAO,MAAM,YAAY,MAAM,KAAG,MAAM,GAAG,SACH,CAAC;AAEjF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,SACrB,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,iBAClC,MAAM,KACpB,MAAM,GAAG,SAOX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,SAAU,MAAM,KAAG,MAAmC,CAAC;AAE7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,WACV,MAAM,YACJ,MAAM,GAAG,SAAS,QACvB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,KAChC,OAAO,GAAG,SAAiF,CAAC;AAE/F;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS,WACZ,MAAM,SACP,MAAM,GAAG,SAAS,YACf,MAAM,GAAG,SAAS,UACpB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,QAC/B,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM;;;wGAgB/B,CAAC;AAEL;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,WAAY,MAAM,QAAQ,MAAM,MAAM,MAAM,uCAOvE,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * ⛔ THE FIELDS A WRITE WOULD SILENTLY DESTROY. Because the write is a full replace, a role
3
+ * someone tuned by hand — `allowed_user_key_lengths` (a minimum RSA size), a
4
+ * `key_id_format` the audit trail is grepped by, a `not_before_duration` widened to
5
+ * survive clock skew, a non-default issuer — loses all of it the moment this resource
6
+ * decides to update. A vanished key-length floor is a security regression that no plan
7
+ * output would ever mention, and a vanished backdating window breaks signing on exactly
8
+ * the skewed host it was added for. So reconcile refuses instead of writing.
9
+ */
10
+ export declare const wouldErase: (live: Record<string, unknown>) => readonly string[];
11
+ //# sourceMappingURL=ssh-role-erase.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssh-role-erase.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role-erase.ts"],"names":[],"mappings":"AAkCA;;;;;;;;GAQG;AACH,eAAO,MAAM,UAAU,SAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,SAAS,MAAM,EAMzE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAQrB,MAAM,oBAAoB,CAAC;AAE5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAyDH,eAAO,MAAM,kBAAkB,iGAqH5B,CAAC"}
1
+ {"version":3,"file":"ssh-role.d.ts","sourceRoot":"","sources":["../../src/openbao/ssh-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,oBAAoB,EAEzB,KAAK,eAAe,EAOrB,MAAM,oBAAoB,CAAC;AAI5B,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,CAAC;AAEtD,MAAM,WAAW,UAAW,SAAQ,QAAQ,CAC1C,aAAa,EACb,eAAe,EACf,oBAAoB,EACpB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,UAAU,6CAErB,CAAC;AAeH,eAAO,MAAM,kBAAkB,iGAwH5B,CAAC"}
@@ -0,0 +1,24 @@
1
+ # Caddy's admin endpoint — `@homeflare/alchemy/caddy`
2
+
3
+ How the provider reaches Caddy's admin API, and what it refuses so a Caddyfile cannot cut it off.
4
+ The overview is [caddy.md](./caddy.md).
5
+
6
+ ⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
7
+ (`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
8
+
9
+ - **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
10
+ guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
11
+ `enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
12
+ Host check entirely.
13
+ - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
14
+ ITS port): pass `hostHeader: 'localhost:2019'`.
15
+ - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
16
+ `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
17
+ - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
18
+ if it turns the API off, listens off loopback or somewhere the transport does not reach (another
19
+ port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
20
+ no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
21
+ `enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
22
+ - ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
23
+ load. That is refused unless the transport is at that default: a Caddy reached on a socket or
24
+ another port declares `admin <address>` in its Caddyfile.
package/docs/caddy.md CHANGED
@@ -24,10 +24,12 @@ export const edge = Effect.gen(function* () {
24
24
  yield* LaunchdJob('caddy-daemon', {
25
25
  label: 'com.example.caddy',
26
26
  domain: 'system',
27
+ // --resume: a restart runs the last config Caddy accepted (see Managed Caddies below).
27
28
  // --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
28
29
  programArguments: [
29
30
  '/usr/local/bin/caddy',
30
31
  'run',
32
+ '--resume',
31
33
  '--config',
32
34
  file.path,
33
35
  '--adapter',
@@ -35,6 +37,8 @@ export const edge = Effect.gen(function* () {
35
37
  '--envfile',
36
38
  '/usr/local/etc/caddy.env',
37
39
  ],
40
+ // ⛔ Here, not in the envfile: Caddy fixes its autosave path before it reads --envfile.
41
+ environment: { XDG_CONFIG_HOME: '/usr/local/var/caddy-edge' },
38
42
  runAtLoad: true,
39
43
  keepAlive: true,
40
44
  });
@@ -56,15 +60,15 @@ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
56
60
  | `endpoint` | where the admin API was reached |
57
61
  | `sourceFile` | as applied |
58
62
 
59
- | step | what happens |
60
- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
61
- | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
62
- | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
63
- | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
64
- | read | the digest of `GET /config/` — with no state, a running Caddy is **adopted** (its live config becomes the baseline) |
65
- | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
66
- | replace | never — a new Caddyfile is a reload of the same Caddy |
67
- | delete | ⛔ nothing. See [Removing it](#removing-it) |
63
+ | step | what happens |
64
+ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
66
+ | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
67
+ | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
68
+ | read | the digest of `GET /config/`; with no state, the adoption probe — ⛔ a config that is not the declared one needs `--adopt` ([Adoption](#adoption)) |
69
+ | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
70
+ | replace | never — a new Caddyfile is a reload of the same Caddy |
71
+ | delete | ⛔ nothing. See [Removing it](#removing-it) |
68
72
 
69
73
  The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
70
74
  keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
@@ -86,31 +90,42 @@ before any admin API exists to load into. What bounds the cost:
86
90
  `/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
87
91
  adoption probe, and the check runs at apply instead.
88
92
  - ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
89
- disk when `/load` fails. Caddy keeps serving the old config — but a **restart before the fix loads
90
- the refused file**. Fix and redeploy, or run Caddy with `--resume` (below).
93
+ disk when `/load` fails. Caddy keeps serving the old config, and a managed Caddy's restart runs its
94
+ autosave, not the refused file (below). Without `--resume` a **restart before the fix loads the
95
+ refused file**: fix and redeploy.
91
96
  - ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
92
97
  - ★ Both resources retain: removing them from a stack leaves the file a restart needs.
93
98
 
94
- ## Autosave and `--resume`
95
-
96
- After every successful load (ours included) Caddy writes the config JSON to `autosave.json`
97
- (`$XDG_CONFIG_HOME/caddy/`, else `~/Library/Application Support/Caddy/` on macOS, `~/.config/caddy/`
98
- on Linux), unless the Caddyfile says `persist_config off`. ⚠️ A daemon started with no `HOME` and no
99
- `XDG_CONFIG_HOME` falls back to `./caddy/` under its working directory (storage.go AppConfigDir):
100
- set `XDG_CONFIG_HOME` in the launchd job so autosave lands where you expect.
101
-
102
- - **Without `--resume`** (the mini's Caddy today): a restart loads `--config`. The file and the
103
- running config agree after every successful deploy; see the ⚠️ above for a failed one.
104
- - **With `caddy run --resume --config <file>`**: a restart loads `autosave.json` — the last config
105
- Caddy **accepted** — and `--config` only when no autosave exists. A refused file on disk can then
106
- never take sites down. The trade: a hand change through the API also survives a restart; the next
107
- plan reports it as drift and the deploy puts the declaration back.
108
- - `--watch` reloads the file on change; our `/load` right after is then a no-op ("config is
109
- unchanged").
110
- - `sourceFile` rides as `Caddy-Config-Source-File`, as `caddy reload` sends it. Without it a load
111
- makes Caddy forget its source file, and SIGUSR1 stops reloading from it. The header only KEEPS the
112
- file Caddy started with (caddy.go ClearLastConfigIfDifferent); it never sets a new one. ⚠️ Under
113
- `--resume` with an autosave, Caddy records no source file at all, so SIGUSR1 has nothing to reload.
99
+ ## Managed Caddies: `--resume` and their own config dir
100
+
101
+ ★ **Decision, 2026-09-21.** A Caddy this package manages runs `caddy run --resume --config <file>`
102
+ with `XDG_CONFIG_HOME` set to a directory of its own, so a restart runs the last config Caddy
103
+ **accepted**. After every successful load (ours included) Caddy writes the config JSON to
104
+ `$XDG_CONFIG_HOME/caddy/autosave.json`; `--resume` starts from it, and from `--config` only when no
105
+ autosave exists (cmd/commandfuncs.go cmdRun). Read in caddyserver/caddy v2.11.4:
106
+
107
+ - ★ **A refused Caddyfile on disk cannot take sites down.** The file is the first-boot fallback: a
108
+ new host, or an autosave someone deleted.
109
+ - ★ **Its own directory,** so nothing else (a Homebrew service, a person's `caddy run`) writes the
110
+ autosave it resumes from. With neither `HOME` nor `XDG_CONFIG_HOME` a daemon would use `./caddy/`
111
+ under its working directory (storage.go AppConfigDir).
112
+ - ⛔ **Set `XDG_CONFIG_HOME` in the job's `environment`, not the envfile.** The autosave path is
113
+ fixed when the binary starts (storage.go `var ConfigAutosavePath`), before `--envfile` is read.
114
+ - ⛔ **No `persist_config off`** in a managed Caddyfile. It stops the autosave, so `--resume` starts
115
+ from a stale one (the last config before it) or, with none, from the file.
116
+ - ⚠️ **A hand change through the API survives a restart.** The next plan reports it as drift, and the
117
+ deploy puts the declaration back.
118
+ - ⚠️ **SIGUSR1 is not a way to change config.** After a resumed start Caddy records no source file
119
+ (cmdRun calls `SetLastConfig` only after loading `--config`), so SIGUSR1 is ignored with the log
120
+ line `last config unknown`. Our `Caddy-Config-Source-File` header keeps a source Caddy already
121
+ has (caddy.go ClearLastConfigIfDifferent) and never sets one. After a first boot from the file,
122
+ SIGUSR1 still reloads it: the text the deploy wrote. Change config by deploying. Editing the file
123
+ and running `caddy reload` is a hand `/load`, which the next plan reports as drift.
124
+ - ⛔ **No `--watch`.** After a resumed start it has no file to watch, and falls back to a `Caddyfile`
125
+ in the working directory (cmd/main.go watchConfigFile).
126
+
127
+ A Caddy not yet moved over (no `--resume`) restarts from `--config`: see the ⚠️ in
128
+ [Order](#order-file-then-load).
114
129
 
115
130
  ## Secrets
116
131
 
@@ -133,31 +148,33 @@ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a sc
133
148
 
134
149
  ## The admin endpoint
135
150
 
136
- ⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
137
- (`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
138
-
139
- - **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
140
- guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
141
- `enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
142
- Host check entirely.
143
- - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
144
- ITS port): pass `hostHeader: 'localhost:2019'`.
145
- - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
146
- `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
147
- - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
148
- if it turns the API off, listens off loopback or somewhere the transport does not reach (another
149
- port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
150
- no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
151
- `enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
152
- - ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
153
- load. That is refused unless the transport is at that default: a Caddy reached on a socket or
154
- another port declares `admin <address>` in its Caddyfile.
151
+ ⛔ The admin API has no authentication: `localCaddyAdmin()` accepts only `http://` loopback or
152
+ `unix:///path` (default `http://127.0.0.1:2019`), and a Caddyfile whose `admin` block would move,
153
+ expose or lock out the API is refused before it loads. Host/Origin checks, narrowed `origins`, SSH
154
+ forwards and every refusal: [caddy-admin.md](./caddy-admin.md).
155
155
 
156
156
  ## Adoption
157
157
 
158
- With no state, `read` reports the running Caddy's live config as plain (owned) attributes, so the
159
- first deploy adopts it silently and its forced update loads the declared Caddyfile — a full replace
160
- of whatever ran before. ⚠️ Review the plan: the Caddy on this loopback admin API is the one you get.
158
+ ★ **Nothing is adopted silently** (decision, 2026-09-21) — the rule `HostFile` and `LaunchdJob` keep.
159
+ With no state, `read` is Alchemy's adoption probe, and what the Caddy runs decides:
160
+
161
+ | running | read | plan |
162
+ | ----------------------------------- | --------- | ---------------------------------------------------------- |
163
+ | the declared config (same digest) | ours | adopted; the forced update loads nothing |
164
+ | nothing (`null`, or no apps) | absent | create — nothing to take over, as an empty R2 lock is none |
165
+ | anything else | `Unowned` | refused unless the deploy runs with `--adopt` |
166
+ | a Caddyfile that cannot be compared | `Unowned` | the same, with a warning saying why |
167
+ | no Caddy answering | absent | create, with a warning; the apply checks again |
168
+
169
+ - ⚠️ **Alchemy skips the probe while the props hold an Output** — always on `caddyWithFile()`'s first
170
+ deploy, whose `sourceFile` is the HostFile's path. So the apply of a create checks too, and fails
171
+ before any `/load` unless the deploy runs with `--adopt`. The HostFile is written by then (a file
172
+ already at the path is the HostFile's own `Unowned`); under `--resume` a restart still runs the
173
+ autosave.
174
+ - ★ **That apply-time check resolves adoption as the planner does:** a resource-scoped `adopt(…)`,
175
+ else `--adopt`. So `.pipe(adopt(false))` still refuses under `--adopt`, and `adopt(true)` works.
176
+ - ★ **An uncomparable Caddyfile is `Unowned`, not an error**: the same read recovers an interrupted
177
+ create with that deploy's props, and a throw would fail every later plan, the fixed one included.
161
178
 
162
179
  ## Removing it
163
180
 
@@ -177,4 +194,6 @@ it by hand at its admin API.
177
194
  its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
178
195
  `retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
179
196
  - ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
180
- Caddy plans an update that loads there; the old one keeps what it had.
197
+ Caddy plans an update; the old one keeps what it had. ⛔ State vouches only for the Caddy it was
198
+ applied to, so the apply loads there only if it serves nothing, runs the declared config or the
199
+ one last stored — anything else needs `--adopt`.
@@ -0,0 +1,122 @@
1
+ # Deploying a Mac host as yourself — `sudoRunner()`
2
+
3
+ A host stack that declares system daemons needs root for a handful of calls. `sudoRunner()` lets
4
+ the deploy run as you (the operator) and sends **only those calls** through `sudo -n`, in fixed
5
+ argv shapes, logging each one. It is an explicit opt-in: nothing falls back to it, and
6
+ `localRunner()` stays the default and never elevates.
7
+
8
+ ```ts
9
+ import { launchdProviders, sudoRunner } from '@homeflare/alchemy/launchd';
10
+
11
+ const runner = sudoRunner({
12
+ // ⛔ Required: the directories root may write. Each must be a real directory that root owns
13
+ // and only root may write; that is checked at every privileged call.
14
+ prefixes: ['/Library/LaunchDaemons', '/opt/example'],
15
+ // log: (line) => …, // default: one line on stderr per privileged call
16
+ });
17
+ // Provide launchdProviders(runner) alongside the stack's other provider layers.
18
+ ```
19
+
20
+ ## What runs as root: the whole list
21
+
22
+ Each is `/usr/bin/sudo -n -- <argv>`, every program by absolute path, never through a shell.
23
+
24
+ | when | argv |
25
+ | -------------------------------- | ---------------------------------------------------------------------- |
26
+ | a system job is bootstrapped | `/bin/launchctl bootstrap system /Library/LaunchDaemons/<label>.plist` |
27
+ | a system job is booted out | `/bin/launchctl bootout system/<label>` |
28
+ | (your own code, via `exec`) | `/bin/launchctl kickstart [-k] [-p] system/<label>` |
29
+ | a file under a prefix is written | `/usr/bin/install -S -m <0644> [-o <uid>] [-g <gid>] <staged> <path>` |
30
+ | a file under a prefix is removed | `/bin/rm -f -- <path>` |
31
+
32
+ - A file's bytes never reach argv or the log: they are written, as you, to a `0600` file in a
33
+ fresh `0700` temp directory. `install` copies that file (and only that file) into place, and
34
+ the temp directory is removed afterwards, whether or not the install succeeded.
35
+ - `install` writes a temp file beside the target and renames it (install(1) on macOS 27.2), so the
36
+ target is never torn. `-S` adds the fsync.
37
+ - Owner and group are numeric ids, resolved by the provider before the call.
38
+
39
+ ## What stays as you
40
+
41
+ - `launchctl print` and `print-disabled`. launchctl(1): "Anyone may read or query the system
42
+ domain"; measured on macOS 27.2, too (see `src/launchd/launchctl.ts`).
43
+ - User and group lookups, `id -G`, every file read and `lstat`.
44
+ - Files outside every prefix, and your own `gui/<uid>` jobs.
45
+
46
+ ★ So **a plan never calls sudo.** `read` and `diff` are reads; only an apply elevates.
47
+
48
+ ## Refused before sudo is asked
49
+
50
+ - Any other argv. That includes a bare `bootout system` (which removes the whole system domain),
51
+ `bootstrap system <directory>` (which loads every plist in it), a plist anywhere but
52
+ `/Library/LaunchDaemons/<label>.plist` (the only place launchd loads daemons from at boot),
53
+ `rm -r`, `install -d`, a label under `org.nixos.`, `com.apple.` or `homebrew.mxcl.`, and any
54
+ argv whose program is `sudo`.
55
+ - A system job's `bootstrap` or `bootout` when `/Library/LaunchDaemons` is not a prefix. ⚠️ Without
56
+ it a delete would boot the job out, then fail to remove its plist, which launchd loads again at
57
+ the next boot.
58
+ - A prefix that is missing, a symlink, not owned by root, or writable by group or other. ⛔ A
59
+ symlinked prefix makes root write wherever it points, a path the log never names.
60
+ - A path outside every prefix that needs root: another user as the owner, say.
61
+ - A symlink or missing directory between the prefix and the file, or anything but a regular file
62
+ at the path. ⚠️ `install src <directory>` copies _into_ the directory, and a symlink to one does
63
+ the same.
64
+ - A file you could not read back. Both providers read every write back, and read again at every
65
+ plan, as you. A root-only `0600` would be installed, then fail with `EACCES` and stay on disk
66
+ with no state. Membership comes from `id -G`, because macOS caps `getgroups()` at 16 (measured
67
+ 2026-09-21: 16 against 18).
68
+ - Another user's `gui/<uid>` or `user/<uid>` domain. Only the system domain is elevated.
69
+
70
+ Every refusal above throws `SudoRefusedError`: nothing ran as root. A privileged command that ran
71
+ and failed throws a plain `Error` with its exit code.
72
+
73
+ ## When sudo wants a password
74
+
75
+ `sudo -n` never prompts. It fails at once, and the error says so: `a password is required, and
76
+ this runner never prompts`. The command did not run. You have two options.
77
+
78
+ 1. **`sudo -v` just before the deploy.** This caches a ticket. By default it lasts 5 minutes and
79
+ is tied to one terminal (sudoers(5): `timestamp_timeout`, `timestamp_type`).
80
+ ⚠️ A deploy that outlives the ticket fails at its next privileged call. Every step is
81
+ idempotent (Alchemy requires `delete` to be), so `sudo -v` and deploying again converges. The
82
+ exception is a `LaunchdJob` label or domain change: it is delete-first, so if it fails between
83
+ the delete and the create, the job is down until you redeploy.
84
+ 2. **A `NOPASSWD` sudoers rule for exactly the shapes above.**
85
+ - ⛔ **That rule is root-equivalent.** Anyone who may `install` into `/Library/LaunchDaemons`
86
+ and `launchctl bootstrap system` can run any program as root. The allowlist protects you
87
+ from mistakes, not from the account that holds the rule.
88
+ - ⛔ **Never use wildcards in the arguments.** sudoers(5): in arguments, `*` also matches spaces
89
+ and `/`, so `/bin/rm -f -- /opt/example/*` matches `../../etc/x` and two paths at once. Use
90
+ `^…$` regular expressions (sudo 1.9.10 and later; macOS 27.2 ships 1.9.17p2) and forbid
91
+ `..`. Check the file with `visudo -c -f <file>` before you install it.
92
+ - ⚠️ The kit ships no tested rule. Nothing here has run sudo.
93
+
94
+ ⚠️ The error text sudo prints is **reasoned, not measured**. It comes from sudoers(5) and sudo(8)
95
+ for sudo 1.9.17p2. A sudo refusal the runner does not recognise comes back as a failed command,
96
+ with sudo's own stderr.
97
+
98
+ ## The log
99
+
100
+ Each privileged call logs one line before it runs:
101
+
102
+ ```text
103
+ homeflare/launchd sudo -n ["/bin/launchctl","bootstrap","system","/Library/LaunchDaemons/com.example.exporter.plist"]
104
+ ```
105
+
106
+ The argv is written as JSON, so a space in a path cannot hide a second argument. The line holds
107
+ the argv only, never file content. ⛔ A custom `log` should keep every line: the log is the only
108
+ record of what ran as root.
109
+
110
+ ## Limits
111
+
112
+ - ⚠️ **Moving a job from `system` to another user's `gui/<uid>` passes the plan and fails at
113
+ apply**, after the old job was already removed. The runner reports `privileged: true`, so the
114
+ plan-time check lets it through, and the refusal only comes at the write. Do that move as a
115
+ remove, then an add.
116
+ - ⚠️ **Under a prefix, the runner writes as root.** An omitted owner is root, and an omitted group
117
+ is the directory's group (a new file's group on macOS).
118
+ - ⚠️ **The checks guard against a mistaken declaration, not against a hostile one.** They run as
119
+ you, just before the call. The prefix itself must be root-only, but a directory _below_ it that
120
+ another user owns is not checked for that, and that user could swap what lies under it in
121
+ between. Keep every directory under a prefix root-owned, too. ACLs are not read, only mode bits.
122
+ - ⛔ **macOS hosts.** Every argv shape was checked against macOS 27.2 man pages.