@homeflare/alchemy 0.5.0 → 0.6.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 (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -7,21 +7,25 @@
7
7
  *
8
8
  * ★ `defaultRemovalPolicy: 'retain'` — disabling a mount destroys every secret under it.
9
9
  * Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
10
+ *
11
+ * ★ REPLACE SEMANTICS (audited 2026-09-21, see src/openbao/REPLACE.md):
12
+ * · `type` changed → plans `replace`, and the APPLY FAILS, destroying nothing: the new
13
+ * generation's path is the old one's, still occupied, so its reconcile dies "type is
14
+ * immutable" before any write (mount-reconcile.ts). Change a type by hand. ⛔ Not `deleteFirst`
15
+ * on purpose — under `destroy` that would disable the mount, every secret with it, on a typo.
16
+ * · `path` changed → FAILS the plan unless `remountFrom` names the old path, which is an
17
+ * in-place `update` that moves the mount and its data (mount-move.ts). It used to be an
18
+ * `update` that enabled an EMPTY mount at the new path.
10
19
  */
11
20
  import { Resource } from 'alchemy';
12
21
  import { isResolved } from 'alchemy/Diff';
13
22
  import * as Provider from 'alchemy/Provider';
14
23
  import * as Effect from 'effect/Effect';
15
24
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
- import {
17
- type BaoMountAttributes,
18
- type BaoMountProps,
19
- attributesOf,
20
- matches,
21
- mountPath,
22
- wantsTune,
23
- } from './mount-form.ts';
24
- import { disableMount, enableMount, readMountData, tuneMount } from './mount-wire.ts';
25
+ import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
26
+ import { planMove } from './mount-move.ts';
27
+ import { readMount, reconcileMount } from './mount-reconcile.ts';
28
+ import { disableMount } from './mount-wire.ts';
25
29
 
26
30
  export type { BaoMountAttributes, BaoMountProps };
27
31
 
@@ -37,13 +41,6 @@ export const BaoMount = Resource<BaoMount>('Bao.Mount', {
37
41
  defaultRemovalPolicy: 'retain',
38
42
  });
39
43
 
40
- const readMount = (props: BaoMountProps) =>
41
- Effect.gen(function* () {
42
- const live = yield* readMountData(props.path);
43
- if (live === undefined) return undefined;
44
- return attributesOf(props, live);
45
- });
46
-
47
44
  export const BaoMountProvider = () =>
48
45
  Provider.effect(
49
46
  BaoMount,
@@ -64,6 +61,13 @@ export const BaoMountProvider = () =>
64
61
 
65
62
  diff: Effect.fn(function* ({ news, output }) {
66
63
  if (output === undefined || !isResolved(news)) return undefined;
64
+ /**
65
+ * ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
66
+ * find nothing and plan `update`, which is how an empty mount used to get enabled.
67
+ */
68
+ const move = planMove('Bao.Mount', output.path, news.path, news.remountFrom);
69
+ if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
70
+ if (move.kind === 'move') return { action: 'update' } as const;
67
71
  const live = yield* readMount(news);
68
72
  if (live === undefined) return { action: 'update' } as const;
69
73
  /**
@@ -77,8 +81,9 @@ export const BaoMountProvider = () =>
77
81
  * it is true the engine logs "Retaining replaced resource (removal policy: retain)"
78
82
  * and skips the delete entirely. Every mount this package declares carries the retain
79
83
  * default (see the ★ on the resource below), so a wrong `type` string cannot disable a
80
- * live mount. What it CAN do is try to enable a second mount at an occupied path,
81
- * which OpenBao refuses loudly — a failed deploy, not a lost shelf.
84
+ * live mount. What it CAN do is fail: the new generation reads the SAME path, finds the
85
+ * old type there and dies before any write (mount-reconcile.ts) — a failed deploy, not a
86
+ * lost shelf (corrected 2026-09-21; this said OpenBao refused a second enable).
82
87
  * ⚠️ THAT SAFETY IS THE DECORATION'S, NOT THIS LINE'S. A caller who opts into
83
88
  * `.pipe(RemovalPolicy.destroy())` gets the destructive replace, which is the correct
84
89
  * meaning of opting in and is worth knowing before you type it on a mount.
@@ -91,32 +96,10 @@ export const BaoMountProvider = () =>
91
96
  /**
92
97
  * ★ A REFUSED ENABLE OR TUNE FAILS THE EFFECT WITH OpenBao's OWN `errors`, naming the
93
98
  * method and path — the job the per-command exit-code checks used to do by hand.
99
+ * The body, and the move, live in mount-reconcile.ts.
94
100
  */
95
- reconcile: Effect.fn(function* ({ news }) {
96
- let live = yield* readMount(news);
97
- if (live === undefined) {
98
- yield* enableMount(news);
99
- if (wantsTune(news)) yield* tuneMount(news);
100
- } else if (!matches(live, news)) {
101
- if (live.type !== news.type) {
102
- return yield* Effect.die(
103
- new Error(
104
- `Bao.Mount ${mountPath(news.path)}: live type ${live.type} != ${news.type}. ` +
105
- 'Secrets engine type is immutable — replace manually.',
106
- ),
107
- );
108
- }
109
- yield* tuneMount(news);
110
- }
111
- live = yield* readMount(news);
112
- if (live === undefined) {
113
- return yield* Effect.die(
114
- new Error(
115
- `Bao.Mount ${mountPath(news.path)}: write returned no error but the mount is still absent.`,
116
- ),
117
- );
118
- }
119
- return live;
101
+ reconcile: Effect.fn(function* ({ news, output }) {
102
+ return yield* reconcileMount(news, output?.path);
120
103
  }),
121
104
 
122
105
  /**
@@ -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
+ };