@homeflare/alchemy 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. package/README.md +68 -55
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-runner.d.ts +36 -0
  53. package/dist/launchd/fake-runner.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +19 -0
  55. package/dist/launchd/host-effect.d.ts.map +1 -0
  56. package/dist/launchd/host-file-form.d.ts +24 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -0
  58. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  59. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  60. package/dist/launchd/host-file.d.ts +25 -0
  61. package/dist/launchd/host-file.d.ts.map +1 -0
  62. package/dist/launchd/host-lookup.d.ts +27 -0
  63. package/dist/launchd/host-lookup.d.ts.map +1 -0
  64. package/dist/launchd/index.d.ts +21 -0
  65. package/dist/launchd/index.d.ts.map +1 -0
  66. package/dist/launchd/index.js +969 -0
  67. package/dist/launchd/index.js.map +25 -0
  68. package/dist/launchd/job-form.d.ts +90 -0
  69. package/dist/launchd/job-form.d.ts.map +1 -0
  70. package/dist/launchd/job-lifecycle.d.ts +34 -0
  71. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  72. package/dist/launchd/job-preflight.d.ts +50 -0
  73. package/dist/launchd/job-preflight.d.ts.map +1 -0
  74. package/dist/launchd/job-validate.d.ts +25 -0
  75. package/dist/launchd/job-validate.d.ts.map +1 -0
  76. package/dist/launchd/job.d.ts +34 -0
  77. package/dist/launchd/job.d.ts.map +1 -0
  78. package/dist/launchd/launchctl.d.ts +62 -0
  79. package/dist/launchd/launchctl.d.ts.map +1 -0
  80. package/dist/launchd/local-runner.d.ts +7 -0
  81. package/dist/launchd/local-runner.d.ts.map +1 -0
  82. package/dist/launchd/plist.d.ts +41 -0
  83. package/dist/launchd/plist.d.ts.map +1 -0
  84. package/dist/launchd/providers.d.ts +15 -0
  85. package/dist/launchd/providers.d.ts.map +1 -0
  86. package/dist/launchd/runner.d.ts +91 -0
  87. package/dist/launchd/runner.d.ts.map +1 -0
  88. package/dist/launchd/secret-tripwire.d.ts +24 -0
  89. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  90. package/dist/openbao/auth-method-form.d.ts +7 -0
  91. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  92. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  93. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/auth-method.d.ts +6 -0
  95. package/dist/openbao/auth-method.d.ts.map +1 -1
  96. package/dist/openbao/auth-role-form.d.ts +9 -0
  97. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  98. package/dist/openbao/auth-role.d.ts.map +1 -1
  99. package/dist/openbao/auth-token-form.d.ts +48 -0
  100. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  101. package/dist/openbao/bao-identity.d.ts +81 -0
  102. package/dist/openbao/bao-identity.d.ts.map +1 -0
  103. package/dist/openbao/bao-namespace.d.ts +20 -0
  104. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  105. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  106. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  107. package/dist/openbao/fake-engines.d.ts +30 -0
  108. package/dist/openbao/fake-engines.d.ts.map +1 -0
  109. package/dist/openbao/fake-stack.d.ts +29 -0
  110. package/dist/openbao/fake-stack.d.ts.map +1 -0
  111. package/dist/openbao/host-approles.d.ts +62 -0
  112. package/dist/openbao/host-approles.d.ts.map +1 -0
  113. package/dist/openbao/index.d.ts +19 -0
  114. package/dist/openbao/index.d.ts.map +1 -1
  115. package/dist/openbao/index.js +1518 -310
  116. package/dist/openbao/index.js.map +40 -17
  117. package/dist/openbao/jwt-config-form.d.ts +51 -0
  118. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  119. package/dist/openbao/jwt-config.d.ts +25 -0
  120. package/dist/openbao/jwt-config.d.ts.map +1 -0
  121. package/dist/openbao/jwt-role-form.d.ts +91 -0
  122. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  123. package/dist/openbao/jwt-role.d.ts +31 -0
  124. package/dist/openbao/jwt-role.d.ts.map +1 -0
  125. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  126. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  127. package/dist/openbao/kubernetes-role.d.ts +25 -0
  128. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  129. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  130. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  131. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  132. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  133. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  134. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  135. package/dist/openbao/mfa-totp.d.ts +33 -0
  136. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  137. package/dist/openbao/mfa-wire.d.ts +63 -0
  138. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  139. package/dist/openbao/mount-form.d.ts +7 -0
  140. package/dist/openbao/mount-form.d.ts.map +1 -1
  141. package/dist/openbao/mount-move.d.ts +49 -0
  142. package/dist/openbao/mount-move.d.ts.map +1 -0
  143. package/dist/openbao/mount-path.d.ts +8 -4
  144. package/dist/openbao/mount-path.d.ts.map +1 -1
  145. package/dist/openbao/mount-reconcile.d.ts +16 -0
  146. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  147. package/dist/openbao/mount.d.ts +9 -0
  148. package/dist/openbao/mount.d.ts.map +1 -1
  149. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  150. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  151. package/dist/openbao/pki-role-form.d.ts +38 -30
  152. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  153. package/dist/openbao/pki-role.d.ts.map +1 -1
  154. package/dist/openbao/policy.d.ts +2 -0
  155. package/dist/openbao/policy.d.ts.map +1 -1
  156. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  157. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  158. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  159. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  160. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  161. package/dist/openbao/remount-wire.d.ts +44 -0
  162. package/dist/openbao/remount-wire.d.ts.map +1 -0
  163. package/dist/openbao/rename.d.ts +106 -0
  164. package/dist/openbao/rename.d.ts.map +1 -0
  165. package/dist/openbao/role-reconcile.d.ts +40 -0
  166. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  167. package/docs/caddy.md +180 -0
  168. package/docs/launchd.md +167 -0
  169. package/docs/mesh-node.md +188 -0
  170. package/package.json +12 -2
  171. package/src/caddy/admin-calls.ts +128 -0
  172. package/src/caddy/admin-guard.ts +170 -0
  173. package/src/caddy/admin.ts +74 -0
  174. package/src/caddy/caddyfile-tripwire.ts +133 -0
  175. package/src/caddy/config-form.ts +64 -0
  176. package/src/caddy/config-lifecycle.test.ts +218 -0
  177. package/src/caddy/config-lifecycle.ts +148 -0
  178. package/src/caddy/config.ts +116 -0
  179. package/src/caddy/digest.ts +44 -0
  180. package/src/caddy/fake-caddy.ts +160 -0
  181. package/src/caddy/guards.test.ts +156 -0
  182. package/src/caddy/index.ts +25 -0
  183. package/src/caddy/local-admin.test.ts +160 -0
  184. package/src/caddy/local-admin.ts +189 -0
  185. package/src/caddy/providers.test.ts +244 -0
  186. package/src/caddy/providers.ts +17 -0
  187. package/src/caddy/with-file.ts +62 -0
  188. package/src/cloudflare/fake-mesh.ts +181 -0
  189. package/src/cloudflare/index.ts +9 -3
  190. package/src/cloudflare/mesh-node-api.ts +127 -0
  191. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  192. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  193. package/src/cloudflare/mesh-node-form.ts +115 -0
  194. package/src/cloudflare/mesh-node-harness.ts +124 -0
  195. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  196. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  197. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  198. package/src/cloudflare/mesh-node-token.ts +125 -0
  199. package/src/cloudflare/mesh-node.test.ts +172 -0
  200. package/src/cloudflare/mesh-node.ts +120 -0
  201. package/src/cloudflare/providers.ts +20 -2
  202. package/src/index.ts +2 -0
  203. package/src/launchd/fake-runner.ts +176 -0
  204. package/src/launchd/host-effect.ts +28 -0
  205. package/src/launchd/host-file-form.ts +71 -0
  206. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  207. package/src/launchd/host-file-lifecycle.ts +164 -0
  208. package/src/launchd/host-file.ts +64 -0
  209. package/src/launchd/host-lookup.test.ts +92 -0
  210. package/src/launchd/host-lookup.ts +94 -0
  211. package/src/launchd/index.ts +26 -0
  212. package/src/launchd/job-form.test.ts +171 -0
  213. package/src/launchd/job-form.ts +180 -0
  214. package/src/launchd/job-identity.test.ts +147 -0
  215. package/src/launchd/job-lifecycle.test.ts +220 -0
  216. package/src/launchd/job-lifecycle.ts +194 -0
  217. package/src/launchd/job-preflight.ts +152 -0
  218. package/src/launchd/job-validate.ts +134 -0
  219. package/src/launchd/job.ts +96 -0
  220. package/src/launchd/launchctl.test.ts +181 -0
  221. package/src/launchd/launchctl.ts +188 -0
  222. package/src/launchd/local-runner.test.ts +125 -0
  223. package/src/launchd/local-runner.ts +143 -0
  224. package/src/launchd/plist.test.ts +186 -0
  225. package/src/launchd/plist.ts +156 -0
  226. package/src/launchd/providers.test.ts +222 -0
  227. package/src/launchd/providers.ts +21 -0
  228. package/src/launchd/runner.ts +95 -0
  229. package/src/launchd/secret-tripwire.test.ts +110 -0
  230. package/src/launchd/secret-tripwire.ts +81 -0
  231. package/src/openbao/README.md +181 -0
  232. package/src/openbao/REPLACE.md +163 -0
  233. package/src/openbao/auth-method-form.ts +7 -0
  234. package/src/openbao/auth-method-reconcile.ts +74 -0
  235. package/src/openbao/auth-method.ts +20 -41
  236. package/src/openbao/auth-role-form.test.ts +16 -1
  237. package/src/openbao/auth-role-form.ts +11 -0
  238. package/src/openbao/auth-role.ts +12 -0
  239. package/src/openbao/auth-token-form.ts +161 -0
  240. package/src/openbao/bao-identity.test.ts +131 -0
  241. package/src/openbao/bao-identity.ts +188 -0
  242. package/src/openbao/bao-namespace.ts +32 -0
  243. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  244. package/src/openbao/cloudflare-role.ts +30 -8
  245. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  246. package/src/openbao/fake-engines.ts +119 -0
  247. package/src/openbao/fake-stack.ts +112 -0
  248. package/src/openbao/host-approles.test.ts +121 -0
  249. package/src/openbao/host-approles.ts +126 -0
  250. package/src/openbao/index.ts +19 -0
  251. package/src/openbao/jwt-config-form.ts +161 -0
  252. package/src/openbao/jwt-config.ts +85 -0
  253. package/src/openbao/jwt-role-form.test.ts +130 -0
  254. package/src/openbao/jwt-role-form.ts +231 -0
  255. package/src/openbao/jwt-role.ts +102 -0
  256. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  257. package/src/openbao/kubernetes-role-form.ts +149 -0
  258. package/src/openbao/kubernetes-role.ts +94 -0
  259. package/src/openbao/mfa-enforcement-form.ts +147 -0
  260. package/src/openbao/mfa-enforcement.ts +143 -0
  261. package/src/openbao/mfa-totp-form.ts +155 -0
  262. package/src/openbao/mfa-totp.ts +118 -0
  263. package/src/openbao/mfa-wire.ts +117 -0
  264. package/src/openbao/mfa.test.ts +200 -0
  265. package/src/openbao/mount-form.ts +9 -1
  266. package/src/openbao/mount-move.test.ts +164 -0
  267. package/src/openbao/mount-move.ts +113 -0
  268. package/src/openbao/mount-path.test.ts +11 -1
  269. package/src/openbao/mount-path.ts +17 -11
  270. package/src/openbao/mount-reconcile.ts +75 -0
  271. package/src/openbao/mount.ts +26 -43
  272. package/src/openbao/pki-role-canonical.ts +163 -0
  273. package/src/openbao/pki-role-form.test.ts +112 -0
  274. package/src/openbao/pki-role-form.ts +76 -110
  275. package/src/openbao/pki-role.ts +12 -27
  276. package/src/openbao/policy-rename.test.ts +112 -0
  277. package/src/openbao/policy-wire.test.ts +7 -26
  278. package/src/openbao/policy.ts +27 -4
  279. package/src/openbao/proxmox-role-form.ts +27 -8
  280. package/src/openbao/proxmox-role-wire.ts +24 -0
  281. package/src/openbao/proxmox-role.ts +62 -37
  282. package/src/openbao/remount-wire.test.ts +87 -0
  283. package/src/openbao/remount-wire.ts +112 -0
  284. package/src/openbao/rename-occupied.test.ts +166 -0
  285. package/src/openbao/rename.ts +162 -0
  286. package/src/openbao/role-reconcile.test.ts +104 -0
  287. package/src/openbao/role-reconcile.ts +84 -0
  288. package/src/openbao/role-rename.test.ts +205 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Bao.PkiRole's canonical form — the managed field set, normalised so both sides of a diff are
3
+ * byte-comparable. Split from pki-role-form.ts for the 250-line cap when six fields were added
4
+ * (2026-09-21); the reasoning below moved with the code, unchanged.
5
+ *
6
+ * ★ THE SIX FIELDS ADDED 2026-09-21 — require_cn, enforce_hostnames, key_usage,
7
+ * allowed_domains_template, no_store, generate_lease — WERE ALREADY BEING WRITTEN, just never
8
+ * declared. The role write is a full replace (pki-role.ts), and openbao v2.6.2
9
+ * builtin/logical/pki/path_roles.go pathRoleCreate (:1153-1196) builds each of them from
10
+ * `data.Get`, i.e. its schema default when the request omits it. So every write this resource
11
+ * ever made reset them — a hand-set `no_store` or a narrowed `key_usage` vanished behind a
12
+ * green plan. Their defaults below are OpenBao's own (schema :560-575, :690-720, :800-845), so a
13
+ * role this resource wrote reads back unchanged and plans `noop`.
14
+ */
15
+ import { sha256 } from './digest.ts';
16
+ import { parseDuration, ttlSeconds } from './mount-form.ts';
17
+ import type { BaoPkiRoleProps } from './pki-role-form.ts';
18
+
19
+ /**
20
+ * The managed field set, normalised so both sides of a diff are byte-comparable.
21
+ *
22
+ * ⚠️ KEY ORDER IS THE DIGEST. The props side and the live side are both built by the single
23
+ * canonical() literal below, so `JSON.stringify` emits the same key order for each. Two
24
+ * hand-written object literals would drift apart and produce a forever-diff nobody could
25
+ * read from the plan output.
26
+ */
27
+ export interface BaoPkiRoleCanonical {
28
+ allowBareDomains: boolean;
29
+ allowGlobDomains: boolean;
30
+ allowIpSans: boolean;
31
+ allowLocalhost: boolean;
32
+ allowSubdomains: boolean;
33
+ allowWildcardCertificates: boolean;
34
+ allowedDomains: readonly string[];
35
+ allowedDomainsTemplate: boolean;
36
+ clientFlag: boolean;
37
+ cnValidations: readonly string[];
38
+ enforceHostnames: boolean;
39
+ extKeyUsage: readonly string[];
40
+ generateLease: boolean;
41
+ keyBits: number;
42
+ keyType: string;
43
+ keyUsage: readonly string[];
44
+ /** ⚠️ `-1` means the duration prop did not parse — reconcile refuses rather than writing it. */
45
+ maxTtlSeconds: number;
46
+ noStore: boolean;
47
+ requireCn: boolean;
48
+ serverFlag: boolean;
49
+ signatureBits: number;
50
+ ttlSeconds: number;
51
+ }
52
+
53
+ /** ★ OpenBao's own `key_usage` default (path_roles.go:697-698) — what an omitted field becomes. */
54
+ export const DEFAULT_KEY_USAGE: readonly string[] = [
55
+ 'DigitalSignature',
56
+ 'KeyAgreement',
57
+ 'KeyEncipherment',
58
+ ];
59
+
60
+ const sorted = (values: readonly string[]) => [...values].sort();
61
+
62
+ /** ⚠️ Case-folded for the COMPARE only; the write keeps the author's casing.
63
+ * Why, and what was never measured, is in the pki-role.ts header. `key_usage` gets the same
64
+ * treatment as `ext_key_usage`, for the same reason — REASONED, not measured. */
65
+ const foldedUsages = (values: readonly string[]) => sorted(values.map((v) => v.toLowerCase()));
66
+
67
+ /** The one literal that fixes key order for both sides of the diff. */
68
+ const canonical = (input: BaoPkiRoleCanonical): BaoPkiRoleCanonical => ({
69
+ allowBareDomains: input.allowBareDomains,
70
+ allowGlobDomains: input.allowGlobDomains,
71
+ allowIpSans: input.allowIpSans,
72
+ allowLocalhost: input.allowLocalhost,
73
+ allowSubdomains: input.allowSubdomains,
74
+ allowWildcardCertificates: input.allowWildcardCertificates,
75
+ allowedDomains: sorted(input.allowedDomains),
76
+ allowedDomainsTemplate: input.allowedDomainsTemplate,
77
+ clientFlag: input.clientFlag,
78
+ cnValidations: sorted(input.cnValidations),
79
+ enforceHostnames: input.enforceHostnames,
80
+ extKeyUsage: foldedUsages(input.extKeyUsage),
81
+ generateLease: input.generateLease,
82
+ keyBits: input.keyBits,
83
+ keyType: input.keyType,
84
+ keyUsage: foldedUsages(input.keyUsage),
85
+ maxTtlSeconds: input.maxTtlSeconds,
86
+ noStore: input.noStore,
87
+ requireCn: input.requireCn,
88
+ serverFlag: input.serverFlag,
89
+ signatureBits: input.signatureBits,
90
+ ttlSeconds: input.ttlSeconds,
91
+ });
92
+
93
+ const seconds = (text: string) => parseDuration(text) ?? -1;
94
+
95
+ export const canonicalFromProps = (props: BaoPkiRoleProps): BaoPkiRoleCanonical =>
96
+ canonical({
97
+ allowBareDomains: props.allowBareDomains ?? false,
98
+ allowGlobDomains: props.allowGlobDomains ?? false,
99
+ allowIpSans: props.allowIpSans ?? false,
100
+ allowLocalhost: props.allowLocalhost ?? false,
101
+ allowSubdomains: props.allowSubdomains ?? false,
102
+ allowWildcardCertificates: props.allowWildcardCertificates ?? false,
103
+ allowedDomains: props.allowedDomains,
104
+ allowedDomainsTemplate: props.allowedDomainsTemplate ?? false,
105
+ clientFlag: props.clientFlag ?? true,
106
+ cnValidations: props.cnValidations ?? ['hostname'],
107
+ enforceHostnames: props.enforceHostnames ?? true,
108
+ extKeyUsage: props.extKeyUsage ?? [],
109
+ generateLease: props.generateLease ?? false,
110
+ keyBits: props.keyBits ?? 0,
111
+ keyType: props.keyType ?? 'rsa',
112
+ keyUsage: props.keyUsage ?? DEFAULT_KEY_USAGE,
113
+ maxTtlSeconds: seconds(props.maxTtl),
114
+ noStore: props.noStore ?? false,
115
+ requireCn: props.requireCn ?? true,
116
+ serverFlag: props.serverFlag ?? true,
117
+ signatureBits: props.signatureBits ?? 0,
118
+ ttlSeconds: seconds(props.ttl),
119
+ });
120
+
121
+ const bool = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback);
122
+
123
+ const int = (value: unknown, fallback: number) =>
124
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
125
+
126
+ const list = (value: unknown): readonly string[] =>
127
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
128
+
129
+ const str = (value: unknown, fallback: string) => (typeof value === 'string' ? value : fallback);
130
+
131
+ /**
132
+ * ⚠️ AN ABSENT NEW FIELD READS AS ITS ZERO VALUE, NOT ITS SCHEMA DEFAULT. A role read always carries
133
+ * the six new fields (ToResponseData, path_roles.go:1720-1780; `generate_lease` only when set,
134
+ * and getRole upgrades a missing one to false at :1034), so the fallback only answers a body
135
+ * that is not OpenBao's — and then "false"/"none" reads as drift rather than a false noop.
136
+ */
137
+ export const canonicalFromLive = (live: Record<string, unknown>): BaoPkiRoleCanonical =>
138
+ canonical({
139
+ allowBareDomains: bool(live['allow_bare_domains'], false),
140
+ allowGlobDomains: bool(live['allow_glob_domains'], false),
141
+ allowIpSans: bool(live['allow_ip_sans'], false),
142
+ allowLocalhost: bool(live['allow_localhost'], false),
143
+ allowSubdomains: bool(live['allow_subdomains'], false),
144
+ allowWildcardCertificates: bool(live['allow_wildcard_certificates'], false),
145
+ allowedDomains: list(live['allowed_domains']),
146
+ allowedDomainsTemplate: bool(live['allowed_domains_template'], false),
147
+ clientFlag: bool(live['client_flag'], true),
148
+ cnValidations: list(live['cn_validations']),
149
+ enforceHostnames: bool(live['enforce_hostnames'], false),
150
+ extKeyUsage: list(live['ext_key_usage']),
151
+ generateLease: bool(live['generate_lease'], false),
152
+ keyBits: int(live['key_bits'], 0),
153
+ keyType: str(live['key_type'], 'rsa'),
154
+ keyUsage: list(live['key_usage']),
155
+ maxTtlSeconds: ttlSeconds(live['max_ttl']) ?? -1,
156
+ noStore: bool(live['no_store'], false),
157
+ requireCn: bool(live['require_cn'], false),
158
+ serverFlag: bool(live['server_flag'], true),
159
+ signatureBits: int(live['signature_bits'], 0),
160
+ ttlSeconds: ttlSeconds(live['ttl']) ?? -1,
161
+ });
162
+
163
+ export const digestOf = (form: BaoPkiRoleCanonical): string => sha256(JSON.stringify(form));
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Bao.PkiRole's six fields added 2026-09-21. The part worth pinning: a role written BEFORE they
3
+ * were props carries OpenBao's defaults for them, and must still plan `noop`; a role that differs
4
+ * on any of them must plan `update`, because the full-replace write would reset it.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import {
9
+ type BaoPkiRoleProps,
10
+ attributesOf,
11
+ matches,
12
+ problems,
13
+ writeBody,
14
+ } from './pki-role-form.ts';
15
+
16
+ const PROPS: BaoPkiRoleProps = {
17
+ allowedDomains: ['svc.example.com'],
18
+ allowSubdomains: true,
19
+ maxTtl: '8760h',
20
+ name: 'host-cert',
21
+ ttl: '720h',
22
+ };
23
+
24
+ /** What OpenBao 2.6.2 answers for a role this resource wrote before the six were props. */
25
+ const LIVE: Record<string, unknown> = {
26
+ allow_bare_domains: false,
27
+ allow_glob_domains: false,
28
+ allow_ip_sans: false,
29
+ allow_localhost: false,
30
+ allow_subdomains: true,
31
+ allow_wildcard_certificates: false,
32
+ allowed_domains: ['svc.example.com'],
33
+ allowed_domains_template: false,
34
+ client_flag: true,
35
+ cn_validations: ['hostname'],
36
+ enforce_hostnames: true,
37
+ ext_key_usage: [],
38
+ generate_lease: false,
39
+ key_bits: 0,
40
+ key_type: 'rsa',
41
+ key_usage: ['DigitalSignature', 'KeyAgreement', 'KeyEncipherment'],
42
+ max_ttl: 31536000,
43
+ no_store: false,
44
+ require_cn: true,
45
+ server_flag: true,
46
+ signature_bits: 0,
47
+ ttl: 2592000,
48
+ };
49
+
50
+ const same = (live: Record<string, unknown>, props: BaoPkiRoleProps = PROPS) =>
51
+ matches(attributesOf(props, live), props);
52
+
53
+ describe('pki role — the six added fields', () => {
54
+ it("still plans noop for a role that carries OpenBao's defaults", () => {
55
+ assert.equal(same(LIVE), true);
56
+ });
57
+
58
+ it('plans update when live differs on any of them — the write would reset it', () => {
59
+ const drifted: [string, unknown][] = [
60
+ ['require_cn', false],
61
+ ['enforce_hostnames', false],
62
+ ['key_usage', ['DigitalSignature']],
63
+ ['allowed_domains_template', true],
64
+ ['no_store', true],
65
+ ['generate_lease', true],
66
+ ];
67
+ for (const [field, value] of drifted) {
68
+ assert.equal(same({ ...LIVE, [field]: value }), false, field);
69
+ }
70
+ });
71
+
72
+ it('reads key_usage as a set, case-folded, like ext_key_usage', () => {
73
+ const shuffled = {
74
+ ...LIVE,
75
+ key_usage: ['keyencipherment', 'DigitalSignature', 'KeyAgreement'],
76
+ };
77
+ assert.equal(same(shuffled), true);
78
+ });
79
+
80
+ it('matches declared values once live carries them', () => {
81
+ const props = { ...PROPS, keyUsage: ['DigitalSignature'], noStore: true, requireCn: false };
82
+ const live = { ...LIVE, key_usage: ['DigitalSignature'], no_store: true, require_cn: false };
83
+ assert.equal(same(live, props), true);
84
+ assert.equal(same(LIVE, props), false);
85
+ });
86
+
87
+ it("sends every one of them, with OpenBao's defaults when omitted", () => {
88
+ const body = writeBody(PROPS);
89
+ assert.equal(body['require_cn'], 'true');
90
+ assert.equal(body['enforce_hostnames'], 'true');
91
+ assert.equal(body['key_usage'], 'DigitalSignature,KeyAgreement,KeyEncipherment');
92
+ assert.equal(body['allowed_domains_template'], 'false');
93
+ assert.equal(body['no_store'], 'false');
94
+ assert.equal(body['generate_lease'], 'false');
95
+ });
96
+
97
+ it('sends an empty keyUsage as an empty string, which the server reads as no usages', () => {
98
+ assert.equal(writeBody({ ...PROPS, keyUsage: [] })['key_usage'], '');
99
+ });
100
+ });
101
+
102
+ describe('pki role problems', () => {
103
+ it('passes a sound declaration', () => {
104
+ assert.deepEqual(problems(PROPS), []);
105
+ });
106
+
107
+ it('refuses noStore with generateLease, empty domains and bad durations', () => {
108
+ assert.match(problems({ ...PROPS, generateLease: true, noStore: true }).join(), /noStore/);
109
+ assert.match(problems({ ...PROPS, allowedDomains: [] }).join(), /allowedDomains is empty/);
110
+ assert.match(problems({ ...PROPS, ttl: '4h30m' }).join(), /ttl=4h30m/);
111
+ });
112
+ });
@@ -1,5 +1,13 @@
1
- import { sha256 } from './digest.ts';
2
- import { mountPath, parseDuration, ttlSeconds } from './mount-form.ts';
1
+ import { mountPath, parseDuration } from './mount-form.ts';
2
+ import {
3
+ type BaoPkiRoleCanonical,
4
+ DEFAULT_KEY_USAGE,
5
+ canonicalFromLive,
6
+ canonicalFromProps,
7
+ digestOf,
8
+ } from './pki-role-canonical.ts';
9
+
10
+ export type { BaoPkiRoleCanonical };
3
11
 
4
12
  /** ★ MEASURED: the OpenAPI enum for `key_type` is exactly these four, lowercase. */
5
13
  export type BaoPkiKeyType = 'any' | 'ec' | 'ed25519' | 'rsa';
@@ -56,34 +64,29 @@ export interface BaoPkiRoleProps {
56
64
  cnValidations?: readonly BaoPkiCnValidation[];
57
65
  /** Signature hash bits; `0` auto-detects from key length. One live role pins 256. */
58
66
  signatureBits?: number;
59
- }
60
-
61
- /**
62
- * The managed field set, normalised so both sides of a diff are byte-comparable.
63
- *
64
- * ⚠️ KEY ORDER IS THE DIGEST. The props side and the live side are both built by the single
65
- * canonical() literal below, so `JSON.stringify` emits the same key order for each. Two
66
- * hand-written object literals would drift apart and produce a forever-diff nobody could
67
- * read from the plan output.
68
- */
69
- export interface BaoPkiRoleCanonical {
70
- allowBareDomains: boolean;
71
- allowGlobDomains: boolean;
72
- allowIpSans: boolean;
73
- allowLocalhost: boolean;
74
- allowSubdomains: boolean;
75
- allowWildcardCertificates: boolean;
76
- allowedDomains: readonly string[];
77
- clientFlag: boolean;
78
- cnValidations: readonly string[];
79
- extKeyUsage: readonly string[];
80
- keyBits: number;
81
- keyType: string;
82
- /** ⚠️ `-1` means the duration prop did not parse — reconcile refuses rather than writing it. */
83
- maxTtlSeconds: number;
84
- serverFlag: boolean;
85
- signatureBits: number;
86
- ttlSeconds: number;
67
+ /**
68
+ * ★ THE SIX BELOW (added 2026-09-21) default to OpenBao's OWN values, not closed ones — a role this
69
+ * resource wrote before they were props carries exactly those, so it still plans `noop`. The
70
+ * write already sent them implicitly; see the header of pki-role-canonical.ts.
71
+ */
72
+ /** A request must carry a common_name. Default true. */
73
+ requireCn?: boolean;
74
+ /** Only valid host names in the CN and DNS SANs. Default true. */
75
+ enforceHostnames?: boolean;
76
+ /**
77
+ * Key usages minus the `KeyUsage` prefix. Default `DigitalSignature, KeyAgreement,
78
+ * KeyEncipherment`. ⚠️ `[]` is a real value — it removes every key usage — and is sent as such.
79
+ */
80
+ keyUsage?: readonly string[];
81
+ /** Allowed domains may use identity templates (`{{identity.entity.name}}`). Default false. */
82
+ allowedDomainsTemplate?: boolean;
83
+ /**
84
+ * Do not store issued certificates. Default false. ⛔ They can then be neither listed nor
85
+ * revoked. ⚠️ Implies `generateLease: false`; declaring both true is refused (see problems).
86
+ */
87
+ noStore?: boolean;
88
+ /** Attach an OpenBao lease to every issued certificate. Default false — leases slow startup. */
89
+ generateLease?: boolean;
87
90
  }
88
91
 
89
92
  export interface BaoPkiRoleAttributes extends BaoPkiRoleCanonical {
@@ -96,84 +99,6 @@ export interface BaoPkiRoleAttributes extends BaoPkiRoleCanonical {
96
99
  digest: string;
97
100
  }
98
101
 
99
- const sorted = (values: readonly string[]) => [...values].sort();
100
-
101
- /** ⚠️ Case-folded for the COMPARE only; the write keeps the author's casing.
102
- * Why, and what was never measured, is in the pki-role.ts header. */
103
- const foldedUsages = (values: readonly string[]) => sorted(values.map((v) => v.toLowerCase()));
104
-
105
- /** The one literal that fixes key order for both sides of the diff. */
106
- const canonical = (input: BaoPkiRoleCanonical): BaoPkiRoleCanonical => ({
107
- allowBareDomains: input.allowBareDomains,
108
- allowGlobDomains: input.allowGlobDomains,
109
- allowIpSans: input.allowIpSans,
110
- allowLocalhost: input.allowLocalhost,
111
- allowSubdomains: input.allowSubdomains,
112
- allowWildcardCertificates: input.allowWildcardCertificates,
113
- allowedDomains: sorted(input.allowedDomains),
114
- clientFlag: input.clientFlag,
115
- cnValidations: sorted(input.cnValidations),
116
- extKeyUsage: foldedUsages(input.extKeyUsage),
117
- keyBits: input.keyBits,
118
- keyType: input.keyType,
119
- maxTtlSeconds: input.maxTtlSeconds,
120
- serverFlag: input.serverFlag,
121
- signatureBits: input.signatureBits,
122
- ttlSeconds: input.ttlSeconds,
123
- });
124
-
125
- const seconds = (text: string) => parseDuration(text) ?? -1;
126
-
127
- export const canonicalFromProps = (props: BaoPkiRoleProps): BaoPkiRoleCanonical =>
128
- canonical({
129
- allowBareDomains: props.allowBareDomains ?? false,
130
- allowGlobDomains: props.allowGlobDomains ?? false,
131
- allowIpSans: props.allowIpSans ?? false,
132
- allowLocalhost: props.allowLocalhost ?? false,
133
- allowSubdomains: props.allowSubdomains ?? false,
134
- allowWildcardCertificates: props.allowWildcardCertificates ?? false,
135
- allowedDomains: props.allowedDomains,
136
- clientFlag: props.clientFlag ?? true,
137
- cnValidations: props.cnValidations ?? ['hostname'],
138
- extKeyUsage: props.extKeyUsage ?? [],
139
- keyBits: props.keyBits ?? 0,
140
- keyType: props.keyType ?? 'rsa',
141
- maxTtlSeconds: seconds(props.maxTtl),
142
- serverFlag: props.serverFlag ?? true,
143
- signatureBits: props.signatureBits ?? 0,
144
- ttlSeconds: seconds(props.ttl),
145
- });
146
-
147
- const bool = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback);
148
-
149
- const int = (value: unknown, fallback: number) =>
150
- typeof value === 'number' && Number.isFinite(value) ? value : fallback;
151
-
152
- const list = (value: unknown): readonly string[] =>
153
- Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
154
-
155
- const str = (value: unknown, fallback: string) => (typeof value === 'string' ? value : fallback);
156
-
157
- export const canonicalFromLive = (live: Record<string, unknown>): BaoPkiRoleCanonical =>
158
- canonical({
159
- allowBareDomains: bool(live['allow_bare_domains'], false),
160
- allowGlobDomains: bool(live['allow_glob_domains'], false),
161
- allowIpSans: bool(live['allow_ip_sans'], false),
162
- allowLocalhost: bool(live['allow_localhost'], false),
163
- allowSubdomains: bool(live['allow_subdomains'], false),
164
- allowWildcardCertificates: bool(live['allow_wildcard_certificates'], false),
165
- allowedDomains: list(live['allowed_domains']),
166
- clientFlag: bool(live['client_flag'], true),
167
- cnValidations: list(live['cn_validations']),
168
- extKeyUsage: list(live['ext_key_usage']),
169
- keyBits: int(live['key_bits'], 0),
170
- keyType: str(live['key_type'], 'rsa'),
171
- maxTtlSeconds: ttlSeconds(live['max_ttl']) ?? -1,
172
- serverFlag: bool(live['server_flag'], true),
173
- signatureBits: int(live['signature_bits'], 0),
174
- ttlSeconds: ttlSeconds(live['ttl']) ?? -1,
175
- });
176
-
177
102
  /** ⚠️ Same rendering auth-role-form.ts uses, so `720h` and `2592000` read alike in a plan. */
178
103
  const ttlText = (value: number) => {
179
104
  if (value < 0) return '';
@@ -183,8 +108,6 @@ const ttlText = (value: number) => {
183
108
  return `${String(value)}s`;
184
109
  };
185
110
 
186
- export const digestOf = (form: BaoPkiRoleCanonical) => sha256(JSON.stringify(form));
187
-
188
111
  export const rolePath = (props: { name: string; mount?: string }) =>
189
112
  `${mountPath(props.mount ?? 'pki')}/roles/${props.name}`;
190
113
 
@@ -216,6 +139,10 @@ export const attributesOf = (
216
139
  * ★ `ext_key_usage` is OMITTED when empty rather than sent as `ext_key_usage=`. The write
217
140
  * replaces the whole role, so an absent field already lands on the `[]` default, and
218
141
  * omitting sidesteps how an empty value would be encoded.
142
+ * ⚠️ `key_usage` CANNOT BE OMITTED THAT WAY — its default is NOT empty — so it is always sent, and
143
+ * an empty declaration goes over as `""`. That encoding is now READ, not guessed: for a
144
+ * comma-string-slice field, go-secure-stdlib parseutil.ParseCommaStringSlice (:389-392)
145
+ * answers `""` with an empty list.
219
146
  *
220
147
  * ★ THE BODY FOR `PUT <mount>/roles/<name>`, ALL STRINGS — the same `k=v` pairs `bao write` sent.
221
148
  */
@@ -237,6 +164,12 @@ export const writeBody = (props: BaoPkiRoleProps): Record<string, string> => {
237
164
  signature_bits: String(form.signatureBits),
238
165
  ttl: props.ttl,
239
166
  max_ttl: props.maxTtl,
167
+ require_cn: String(form.requireCn),
168
+ enforce_hostnames: String(form.enforceHostnames),
169
+ key_usage: (props.keyUsage ?? DEFAULT_KEY_USAGE).join(','),
170
+ allowed_domains_template: String(form.allowedDomainsTemplate),
171
+ no_store: String(form.noStore),
172
+ generate_lease: String(form.generateLease),
240
173
  };
241
174
  const usages = props.extKeyUsage ?? [];
242
175
  if (usages.length > 0) body['ext_key_usage'] = usages.join(',');
@@ -246,3 +179,36 @@ export const writeBody = (props: BaoPkiRoleProps): Record<string, string> => {
246
179
  /** True when live already matches the declaration on every managed field. */
247
180
  export const matches = (attributes: BaoPkiRoleAttributes, props: BaoPkiRoleProps) =>
248
181
  attributes.digest === digestOf(canonicalFromProps(props));
182
+
183
+ /**
184
+ * Declarations reconcile must refuse rather than write. diff routes any of these to `update` so the
185
+ * refusal is seen on the next deploy, never hidden behind a `noop`.
186
+ *
187
+ * ⚠️ `noStore` WITH `generateLease` IS NOT AN ERROR ON THE SERVER — pathRoleCreate stores
188
+ * generate_lease false and adds a warning (path_roles.go:1250-1256) — so the role would read back
189
+ * different from its declaration and plan `update` forever. Refused here instead.
190
+ */
191
+ export const problems = (props: BaoPkiRoleProps): readonly string[] => {
192
+ const found: string[] = [];
193
+ if (props.allowedDomains.length === 0) {
194
+ found.push(
195
+ 'allowedDomains is empty. With allow_any_name false that role can issue no certificate at all.',
196
+ );
197
+ }
198
+ const bad = [
199
+ ['ttl', props.ttl],
200
+ ['maxTtl', props.maxTtl],
201
+ ].filter(([, text]) => parseDuration(text ?? '') === undefined);
202
+ if (bad.length > 0) {
203
+ found.push(
204
+ `${bad.map(([k, v]) => `${k ?? ''}=${v ?? ''}`).join(', ')} is not a duration OpenBao ` +
205
+ 'parses (expect 30m, 720h, 8760h or 0).',
206
+ );
207
+ }
208
+ if (props.noStore === true && props.generateLease === true) {
209
+ found.push(
210
+ 'noStore and generateLease are both true; OpenBao keeps no_store and drops the lease.',
211
+ );
212
+ }
213
+ return found;
214
+ };
@@ -44,12 +44,13 @@ 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, parseDuration } from './mount-form.ts';
47
+ import { mountPath } from './mount-form.ts';
48
48
  import {
49
49
  type BaoPkiRoleAttributes,
50
50
  type BaoPkiRoleProps,
51
51
  attributesOf,
52
52
  matches,
53
+ problems,
53
54
  rolePath,
54
55
  writeBody,
55
56
  } from './pki-role-form.ts';
@@ -75,12 +76,6 @@ const readRole = (props: BaoPkiRoleProps) =>
75
76
  return attributesOf(props, live);
76
77
  });
77
78
 
78
- /** Duration props `bao write` must never see — reconcile turns these into a refusal. */
79
- const badDurations = (props: BaoPkiRoleProps) =>
80
- [['ttl', props.ttl] as const, ['maxTtl', props.maxTtl] as const].filter(
81
- ([, text]) => parseDuration(text) === undefined,
82
- );
83
-
84
79
  export const BaoPkiRoleProvider = () =>
85
80
  Provider.effect(
86
81
  BaoPkiRole,
@@ -111,18 +106,20 @@ export const BaoPkiRoleProvider = () =>
111
106
  * ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
112
107
  * engine is a different object under a different CA, never an in-place edit —
113
108
  * `replace`, so Alchemy creates the new one and retires the old.
109
+ * ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
110
+ * so a new `name` read nothing at the new path, planned `update`, wrote the new role,
111
+ * and left the old one issuing certificates under no state record at all.
114
112
  */
115
- if (mountPath(news.mount ?? 'pki') !== output.mount)
113
+ if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
116
114
  return { action: 'replace' } as const;
117
115
  /**
118
116
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
119
117
  * allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
120
118
  * live role already in that state — and a noop there hides the refusal behind a
121
119
  * clean plan until the next deploy. Route it to reconcile, which says why.
120
+ * (The full list is `problems` in pki-role-form.ts.)
122
121
  */
123
- if (news.allowedDomains.length === 0 || badDurations(news).length > 0) {
124
- return { action: 'update' } as const;
125
- }
122
+ if (problems(news).length > 0) return { action: 'update' } as const;
126
123
  const live = yield* readRole(news);
127
124
  if (live === undefined) return { action: 'update' } as const;
128
125
  return matches(live, news)
@@ -136,24 +133,12 @@ export const BaoPkiRoleProvider = () =>
136
133
  * ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
137
134
  * false on every live role, so a role with no allowed domains can issue NOTHING —
138
135
  * and an unresolved or mistyped prop reads exactly like an author who meant it.
139
- * policy.ts refuses an empty fragment directory for the same reason.
136
+ * policy.ts refuses an empty fragment directory for the same reason. The other
137
+ * refusals — an unparseable duration, noStore with generateLease — are in `problems`.
140
138
  */
141
- if (news.allowedDomains.length === 0) {
142
- return yield* Effect.die(
143
- new Error(
144
- `Bao.PkiRole ${path}: allowedDomains is empty. With allow_any_name false ` +
145
- 'that role can issue no certificate at all.',
146
- ),
147
- );
148
- }
149
- const bad = badDurations(news);
139
+ const bad = problems(news);
150
140
  if (bad.length > 0) {
151
- return yield* Effect.die(
152
- new Error(
153
- `Bao.PkiRole ${path}: ${bad.map(([k, v]) => `${k}=${v}`).join(', ')} ` +
154
- 'is not a duration OpenBao parses (expect 30m, 720h, 8760h or 0).',
155
- ),
156
- );
141
+ return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
157
142
  }
158
143
  const live = yield* readRole(news);
159
144
  if (live === undefined || !matches(live, news)) {