@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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * One AppRole role per host, generated from a host list and a few class definitions — pure, so a
3
+ * scaffold test can render every role from one site config and assert on it without a vault.
4
+ *
5
+ * ★ WHY PER HOST, NOT PER CLASS. OpenBao sets an AppRole login's entity alias to the role_id, so
6
+ * every host sharing a class role is ONE identity: one entity in every audit line, and one cached
7
+ * token when two hosts send the same login through a caching proxy (vault consolidation plan,
8
+ * decision 8 as amended 2026-09-21; not re-measured here). The CLASS carries the policies and
9
+ * TTLs; the ROLE carries the host.
10
+ *
11
+ * ★ THE NAMING RULE: `<class>--<host>`, e.g. `pve-node--node-a`.
12
+ * · Class and host names are lowercase letters, digits and SINGLE hyphens, so `--` can only ever
13
+ * be the separator and a name splits back into exactly one (class, host) pair.
14
+ * · Class first, so one class's roles share a prefix a reader (or a policy path glob,
15
+ * `auth/approle/role/pve-node--*`) can select — and no hand-made role (`deploy`,
16
+ * `approle-rotator`) can match it, because none of them contains `--`.
17
+ * · Lowercase because AppRole stores role names lowercased (builtin/credential/approle stores
18
+ * `role/<lowercased name>`); a mixed-case name would read back as a different string.
19
+ *
20
+ * ⛔ NO ROLE WITHOUT AN EXPIRING SECRET_ID. `secretIdTtl` must parse to MORE than zero: `0` is a
21
+ * secret_id that never expires, which is the `deploy` credential the plan is retiring. The plan's
22
+ * class default is 90 days (`2160h`, which auth-role-form.test.ts shows OpenBao accepts).
23
+ * ⛔ `bindSecretId` IS ALWAYS TRUE. A role that logs in on role_id alone makes the role_id — which
24
+ * is not treated as a secret — the whole credential.
25
+ * ⚠️ A RENAMED HOST OR CLASS IS A NEW ROLE: a new role_id, and a new secret_id to push to the host
26
+ * in the same change. What Alchemy calls it depends on the logical id. Keyed by the role name (as
27
+ * the README does), the old id VANISHES from the stack, so it is an orphan delete, not a
28
+ * `replace`. Keyed by something stable, Bao.AuthRole plans a `replace`. Either way the default
29
+ * `retain` leaves the old role LIVE, its secret_ids still logging in (alchemy Plan.ts:2040 and
30
+ * Apply.ts:2152-2173 honour retain on both paths) — see the ⚠️ in auth-role.ts before renaming.
31
+ */
32
+ import type { BaoAuthRoleProps } from './auth-role-form.ts';
33
+ import { parseDuration } from './mount-form.ts';
34
+
35
+ /** What every host of one class gets. */
36
+ export interface HostRoleClass {
37
+ /** Policy names. ⛔ Never empty, never `root`. */
38
+ readonly policies: readonly string[];
39
+ readonly tokenTtl: string;
40
+ readonly tokenMaxTtl: string;
41
+ /** ⛔ Must be more than zero — e.g. `2160h` (90 days). */
42
+ readonly secretIdTtl: string;
43
+ /** `0` (the default) means unlimited uses, which a host that logs in repeatedly needs. */
44
+ readonly secretIdNumUses?: number;
45
+ }
46
+
47
+ export interface HostRoleHost {
48
+ /** Short host name, e.g. `node-a`. */
49
+ readonly name: string;
50
+ /** Key into `classes`. */
51
+ readonly class: string;
52
+ }
53
+
54
+ export interface HostAppRolesInput {
55
+ readonly hosts: readonly HostRoleHost[];
56
+ readonly classes: Readonly<Record<string, HostRoleClass>>;
57
+ }
58
+
59
+ /** Lowercase letters and digits, single hyphens between them. */
60
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
61
+
62
+ export const HOST_ROLE_SEPARATOR = '--';
63
+
64
+ /** The naming rule, in one place: `<class>--<host>`. */
65
+ export const hostRoleName = (className: string, host: string): string =>
66
+ `${className}${HOST_ROLE_SEPARATOR}${host}`;
67
+
68
+ const classProblems = (name: string, spec: HostRoleClass): string[] => {
69
+ const found: string[] = [];
70
+ if (!NAME.test(name))
71
+ found.push(`class \`${name}\` is not lowercase letters, digits, single hyphens`);
72
+ if (spec.policies.length === 0) found.push(`class \`${name}\` has no policies`);
73
+ if (spec.policies.includes('root')) found.push(`class \`${name}\` grants \`root\``);
74
+ const secretId = parseDuration(spec.secretIdTtl);
75
+ if (secretId === undefined || secretId <= 0) {
76
+ found.push(
77
+ `class \`${name}\` secretIdTtl \`${spec.secretIdTtl}\` must be a duration above zero ` +
78
+ '(0 never expires)',
79
+ );
80
+ }
81
+ const ttl = parseDuration(spec.tokenTtl);
82
+ const max = parseDuration(spec.tokenMaxTtl);
83
+ if (ttl === undefined || max === undefined) {
84
+ found.push(`class \`${name}\` token TTLs must be durations like 15m or 1h`);
85
+ } else if (max > 0 && ttl > max) {
86
+ found.push(`class \`${name}\` tokenTtl is longer than tokenMaxTtl`);
87
+ }
88
+ return found;
89
+ };
90
+
91
+ /**
92
+ * Every host's role props, sorted by role name. Throws, naming every problem at once, on a class or
93
+ * host that breaks a rule above — this runs when a stack is defined, so a throw stops the plan.
94
+ */
95
+ export const hostAppRoles = (input: HostAppRolesInput): readonly BaoAuthRoleProps[] => {
96
+ const problems = Object.entries(input.classes).flatMap(([name, spec]) =>
97
+ classProblems(name, spec),
98
+ );
99
+ const seen = new Set<string>();
100
+ const roles: BaoAuthRoleProps[] = [];
101
+ for (const host of input.hosts) {
102
+ if (!NAME.test(host.name)) {
103
+ problems.push(`host \`${host.name}\` is not lowercase letters, digits, single hyphens`);
104
+ }
105
+ if (seen.has(host.name)) problems.push(`host \`${host.name}\` is listed twice`);
106
+ seen.add(host.name);
107
+ const spec = input.classes[host.class];
108
+ if (spec === undefined) {
109
+ problems.push(`host \`${host.name}\` names unknown class \`${host.class}\``);
110
+ continue;
111
+ }
112
+ roles.push({
113
+ bindSecretId: true,
114
+ name: hostRoleName(host.class, host.name),
115
+ secretIdNumUses: spec.secretIdNumUses ?? 0,
116
+ secretIdTtl: spec.secretIdTtl,
117
+ tokenMaxTtl: spec.tokenMaxTtl,
118
+ tokenPolicies: [...new Set(spec.policies)].sort(),
119
+ tokenTtl: spec.tokenTtl,
120
+ });
121
+ }
122
+ if (problems.length > 0) {
123
+ throw new Error(`hostAppRoles refused the input: ${problems.join('; ')}.`);
124
+ }
125
+ return roles.sort((a, b) => a.name.localeCompare(b.name));
126
+ };
@@ -10,6 +10,10 @@
10
10
  * ★ THE APPROLE LOGIN IS HERE FOR SCRIPTS, NOT STACKS (approle-login.ts). A wrapper that logs in
11
11
  * and revokes on exit needs it, and giving it the families' own transport is what keeps a script
12
12
  * and a stack from disagreeing about which server BAO_ADDR means.
13
+ * ★ SO IS THE IDENTITY GUARD (bao-identity.ts), for the opposite reason: a stack calls it FIRST, so
14
+ * no family plans against a vault that is not the one the stack was written for.
15
+ * ★ hostAppRoles IS PURE — no provider, no call — so a scaffold test can render every per-host
16
+ * AppRole from one site config.
13
17
  */
14
18
  export type { AppRoleLogin, AppRoleLoginInput, BaoLoginFailure } from './approle-login.ts';
15
19
  export {
@@ -20,7 +24,10 @@ export {
20
24
  revokeSelfEffect,
21
25
  } from './approle-login.ts';
22
26
  export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
27
+ export type { BaoAuthRoleProps } from './auth-role.ts';
23
28
  export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
29
+ export type { BaoIdentity, BaoIdentityExpected, BaoIdentityFailure } from './bao-identity.ts';
30
+ export { BaoIdentityError, assertBaoIdentity, assertBaoIdentityEffect } from './bao-identity.ts';
24
31
  export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
25
32
  export {
26
33
  CloudflarePermissionGroups,
@@ -31,6 +38,18 @@ export type { CloudflareZone, ExpandedRole } from './cloudflare-roles-expand.ts'
31
38
  export { expandAccount, expandAll, mountName } from './cloudflare-roles-expand.ts';
32
39
  export type { RolesConfig, SurfaceSpec } from './cloudflare-roles-config.ts';
33
40
  export { parseRolesConfig } from './cloudflare-roles-config.ts';
41
+ export type { HostAppRolesInput, HostRoleClass, HostRoleHost } from './host-approles.ts';
42
+ export { HOST_ROLE_SEPARATOR, hostAppRoles, hostRoleName } from './host-approles.ts';
43
+ export type { BaoJwtAuthConfigProps } from './jwt-config.ts';
44
+ export { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
45
+ export type { BaoJwtRoleProps, BaoJwtRoleType } from './jwt-role.ts';
46
+ export { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
47
+ export type { BaoKubernetesAliasSource, BaoKubernetesRoleProps } from './kubernetes-role.ts';
48
+ export { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
49
+ export type { BaoMfaLoginEnforcementProps } from './mfa-enforcement.ts';
50
+ export { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
51
+ export type { BaoMfaTotpMethodProps, BaoTotpAlgorithm } from './mfa-totp.ts';
52
+ export { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
34
53
  export { BaoMount, BaoMountProvider } from './mount.ts';
35
54
  export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
36
55
  export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The form half of Bao.JwtAuthConfig — `auth/<mount>/config` for a mount that VALIDATES JWTs
3
+ * (GitHub Actions OIDC, a Kubernetes issuer, a JWKS endpoint). NON-SECRET FIELDS ONLY.
4
+ *
5
+ * ⛔ `oidc_client_secret` IS NOT A PROP, AND THIS RESOURCE REFUSES TO TOUCH A MOUNT THAT HAS ONE.
6
+ * Three reasons, each sufficient:
7
+ * 1. Alchemy stores props and attributes UNENCRYPTED in its state store (policy.ts), which is
8
+ * dumped nightly to another host. A client secret there outlives the vault that guards it.
9
+ * 2. The read never returns it (path_config.go:266-315 lists every field it answers; the secret
10
+ * is not one, and it is `Sensitive`, :62-68), so a diff could never see it drift.
11
+ * 3. THE WRITE IS A FULL REPLACE. pathConfigWrite (:321-345) builds the whole config from
12
+ * `d.Get`, so a write that leaves the secret out ERASES it — and with `oidc_client_id` still
13
+ * set the server refuses, while with both gone (:382-386) every OIDC browser login breaks.
14
+ * So a mount whose live config carries `oidc_client_id` is refused by `wouldErase`: configure
15
+ * an OIDC login mount by hand, or by a tool that reads the secret from KV at apply time.
16
+ * ⚠️ THE WRITE CALLS OUT. With a discovery URL or JWKS URL the SERVER fetches it during the write
17
+ * (:387-420) and refuses on failure ("error checking oidc discovery URL"): the vault must reach
18
+ * the issuer, not only the machine running the plan.
19
+ */
20
+ import { sha256 } from './digest.ts';
21
+ import { mountPath } from './mount-form.ts';
22
+
23
+ export interface BaoJwtAuthConfigProps {
24
+ /** Auth mount path. Default `jwt`. */
25
+ mount?: string;
26
+ /** Issuer base URL, WITHOUT `/.well-known/openid-configuration` (the server refuses that). */
27
+ oidcDiscoveryUrl?: string;
28
+ /** PEM CA for the discovery URL, when its certificate is not publicly trusted. */
29
+ oidcDiscoveryCaPem?: string;
30
+ /** A JWKS URL, instead of discovery. */
31
+ jwksUrl?: string;
32
+ /** PEM CA for the JWKS URL. */
33
+ jwksCaPem?: string;
34
+ /** PEM PUBLIC keys, instead of either URL. */
35
+ jwtValidationPubkeys?: readonly string[];
36
+ /** Algorithms accepted. Default none — which the server reads as RS256. */
37
+ jwtSupportedAlgs?: readonly string[];
38
+ /** The `iss` a token must carry. */
39
+ boundIssuer?: string;
40
+ /** Role used when a login names none. Lower case. */
41
+ defaultRole?: string;
42
+ }
43
+
44
+ export interface BaoJwtAuthConfigCanonical {
45
+ boundIssuer: string;
46
+ defaultRole: string;
47
+ jwksCaPem: string;
48
+ jwksUrl: string;
49
+ jwtSupportedAlgs: readonly string[];
50
+ jwtValidationPubkeys: readonly string[];
51
+ oidcDiscoveryCaPem: string;
52
+ oidcDiscoveryUrl: string;
53
+ }
54
+
55
+ /** ⛔ No secret — URLs, public keys, CA certificates, names. */
56
+ export interface BaoJwtAuthConfigAttributes extends BaoJwtAuthConfigCanonical {
57
+ mount: string;
58
+ digest: string;
59
+ }
60
+
61
+ const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
62
+ const strings = (value: unknown): readonly string[] =>
63
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
64
+ const sortedSet = (values: readonly string[]) =>
65
+ [...new Set(values.map((value) => value.trim()))].sort();
66
+
67
+ const canonical = (input: BaoJwtAuthConfigCanonical): BaoJwtAuthConfigCanonical => ({
68
+ boundIssuer: input.boundIssuer.trim(),
69
+ defaultRole: input.defaultRole.trim(),
70
+ jwksCaPem: input.jwksCaPem.trim(),
71
+ jwksUrl: input.jwksUrl.trim(),
72
+ jwtSupportedAlgs: sortedSet(input.jwtSupportedAlgs),
73
+ jwtValidationPubkeys: sortedSet(input.jwtValidationPubkeys),
74
+ oidcDiscoveryCaPem: input.oidcDiscoveryCaPem.trim(),
75
+ oidcDiscoveryUrl: input.oidcDiscoveryUrl.trim(),
76
+ });
77
+
78
+ export const canonicalFromProps = (props: BaoJwtAuthConfigProps): BaoJwtAuthConfigCanonical =>
79
+ canonical({
80
+ boundIssuer: props.boundIssuer ?? '',
81
+ defaultRole: props.defaultRole ?? '',
82
+ jwksCaPem: props.jwksCaPem ?? '',
83
+ jwksUrl: props.jwksUrl ?? '',
84
+ jwtSupportedAlgs: props.jwtSupportedAlgs ?? [],
85
+ jwtValidationPubkeys: props.jwtValidationPubkeys ?? [],
86
+ oidcDiscoveryCaPem: props.oidcDiscoveryCaPem ?? '',
87
+ oidcDiscoveryUrl: props.oidcDiscoveryUrl ?? '',
88
+ });
89
+
90
+ export const canonicalFromLive = (live: Record<string, unknown>): BaoJwtAuthConfigCanonical =>
91
+ canonical({
92
+ boundIssuer: text(live['bound_issuer']),
93
+ defaultRole: text(live['default_role']),
94
+ jwksCaPem: text(live['jwks_ca_pem']),
95
+ jwksUrl: text(live['jwks_url']),
96
+ jwtSupportedAlgs: strings(live['jwt_supported_algs']),
97
+ jwtValidationPubkeys: strings(live['jwt_validation_pubkeys']),
98
+ oidcDiscoveryCaPem: text(live['oidc_discovery_ca_pem']),
99
+ oidcDiscoveryUrl: text(live['oidc_discovery_url']),
100
+ });
101
+
102
+ export const mountOf = (props: { mount?: string }): string => mountPath(props.mount ?? 'jwt');
103
+
104
+ export const configPath = (props: { mount?: string }): string => `auth/${mountOf(props)}/config`;
105
+
106
+ export const attributesOf = (
107
+ props: BaoJwtAuthConfigProps,
108
+ live: Record<string, unknown>,
109
+ ): BaoJwtAuthConfigAttributes => {
110
+ const form = canonicalFromLive(live);
111
+ return { ...form, digest: sha256(JSON.stringify(form)), mount: mountOf(props) };
112
+ };
113
+
114
+ export const matches = (attributes: BaoJwtAuthConfigAttributes, props: BaoJwtAuthConfigProps) =>
115
+ attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
116
+
117
+ /** Every non-secret field — the write is a full replace, so what is left out is reset. */
118
+ export const writeBody = (props: BaoJwtAuthConfigProps): Record<string, unknown> => ({
119
+ bound_issuer: props.boundIssuer ?? '',
120
+ default_role: props.defaultRole ?? '',
121
+ jwks_ca_pem: props.jwksCaPem ?? '',
122
+ jwks_url: props.jwksUrl ?? '',
123
+ jwt_supported_algs: [...(props.jwtSupportedAlgs ?? [])],
124
+ jwt_validation_pubkeys: [...(props.jwtValidationPubkeys ?? [])],
125
+ oidc_discovery_ca_pem: props.oidcDiscoveryCaPem ?? '',
126
+ oidc_discovery_url: props.oidcDiscoveryUrl ?? '',
127
+ });
128
+
129
+ /** Live fields the full-replace write would reset, which this resource deliberately does not model. */
130
+ export const wouldErase = (live: Record<string, unknown>): readonly string[] => {
131
+ const lost: string[] = [];
132
+ if (text(live['oidc_client_id']) !== '') lost.push('oidc_client_id (and its unreadable secret)');
133
+ const provider = live['provider_config'];
134
+ if (typeof provider === 'object' && provider !== null && Object.keys(provider).length > 0) {
135
+ lost.push('provider_config');
136
+ }
137
+ if (text(live['oidc_response_mode']) !== '') lost.push('oidc_response_mode');
138
+ if (strings(live['oidc_response_types']).length > 0) lost.push('oidc_response_types');
139
+ if (strings(live['override_allowed_server_names']).length > 0) {
140
+ lost.push('override_allowed_server_names');
141
+ }
142
+ return lost;
143
+ };
144
+
145
+ export const problems = (props: BaoJwtAuthConfigProps): readonly string[] => {
146
+ const found: string[] = [];
147
+ const sources = [
148
+ (props.oidcDiscoveryUrl ?? '') !== '',
149
+ (props.jwksUrl ?? '') !== '',
150
+ (props.jwtValidationPubkeys?.length ?? 0) > 0,
151
+ ].filter(Boolean).length;
152
+ if (sources !== 1) {
153
+ found.push('exactly one of oidcDiscoveryUrl, jwksUrl or jwtValidationPubkeys must be set');
154
+ }
155
+ if ((props.oidcDiscoveryUrl ?? '').includes('.well-known')) {
156
+ found.push('oidcDiscoveryUrl must be the issuer, without /.well-known/openid-configuration');
157
+ }
158
+ const role = props.defaultRole ?? '';
159
+ if (role !== role.toLowerCase()) found.push(`defaultRole \`${role}\` must be lower case`);
160
+ return found;
161
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `auth/<mount>/config` for a JWT-validating mount — the issuer, keys and algorithms a login token
3
+ * is checked against. OPTIONAL, NON-SECRET, and deliberately unable to configure an OIDC browser
4
+ * login: read the ⛔ in jwt-config-form.ts for why `oidc_client_secret` stays out of Alchemy.
5
+ *
6
+ * ⚠️ THERE IS NO DELETE. openbao v2.6.2 path_config.go:126-141 defines read and update only; the
7
+ * config lives exactly as long as its mount. So `delete` here writes nothing and says so in its
8
+ * comment — it cannot be made to "remove" a config, and refusing would wedge every destroy. The
9
+ * `retain` default means it only runs when a stack opted into destroy.
10
+ *
11
+ * ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
12
+ * the old one keeps its own until that mount is disabled. There is no identity but the mount.
13
+ */
14
+ import { Resource } from 'alchemy';
15
+ import { isResolved } from 'alchemy/Diff';
16
+ import * as Provider from 'alchemy/Provider';
17
+ import * as Effect from 'effect/Effect';
18
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
19
+ import {
20
+ type BaoJwtAuthConfigAttributes,
21
+ type BaoJwtAuthConfigProps,
22
+ attributesOf,
23
+ configPath,
24
+ matches,
25
+ mountOf,
26
+ problems,
27
+ wouldErase,
28
+ writeBody,
29
+ } from './jwt-config-form.ts';
30
+ import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
31
+
32
+ export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
33
+
34
+ export interface BaoJwtAuthConfig extends Resource<
35
+ 'Bao.JwtAuthConfig',
36
+ BaoJwtAuthConfigProps,
37
+ BaoJwtAuthConfigAttributes,
38
+ never,
39
+ HttpClient.HttpClient
40
+ > {}
41
+
42
+ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig', {
43
+ defaultRemovalPolicy: 'retain',
44
+ });
45
+
46
+ export const jwtConfigSpec = (
47
+ props: BaoJwtAuthConfigProps,
48
+ ): RoleSpec<BaoJwtAuthConfigAttributes> => ({
49
+ attributesOf: (live) => attributesOf(props, live),
50
+ body: writeBody(props),
51
+ family: 'Bao.JwtAuthConfig',
52
+ matches: (attributes) => matches(attributes, props),
53
+ path: configPath(props),
54
+ problems: problems(props),
55
+ wouldErase,
56
+ });
57
+
58
+ export const BaoJwtAuthConfigProvider = () =>
59
+ Provider.effect(
60
+ BaoJwtAuthConfig,
61
+ Effect.succeed(
62
+ BaoJwtAuthConfig.Provider.of({
63
+ list: () => Effect.succeed([]),
64
+
65
+ read: Effect.fn(function* ({ olds }) {
66
+ const found = yield* readRoleAt(jwtConfigSpec(olds));
67
+ return found?.attributes;
68
+ }),
69
+
70
+ diff: Effect.fn(function* ({ news, output }) {
71
+ if (output === undefined || !isResolved(news)) return undefined;
72
+ if (mountOf(news) !== output.mount) return { action: 'replace' } as const;
73
+ return { action: yield* planRole(jwtConfigSpec(news)) } as const;
74
+ }),
75
+
76
+ /** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
77
+ reconcile: Effect.fn(function* ({ news }) {
78
+ return yield* reconcileRole(jwtConfigSpec(news));
79
+ }),
80
+
81
+ /** ⚠️ Writes nothing — there is no delete endpoint (header). */
82
+ delete: () => Effect.void,
83
+ }),
84
+ ),
85
+ );
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Bao.JwtRole's form. Worth pinning: the body carries the four fields the server RESETS when they
3
+ * are omitted, maps go over as objects, a role read back from OpenBao compares equal to its
4
+ * declaration, and the server's own refusals are refused here first. Values are placeholders.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import {
9
+ type BaoJwtRoleProps,
10
+ attributesOf,
11
+ matches,
12
+ problems,
13
+ writeBody,
14
+ } from './jwt-role-form.ts';
15
+
16
+ const CI: BaoJwtRoleProps = {
17
+ boundAudiences: ['https://github.com/example-org'],
18
+ boundClaims: { ref: 'refs/heads/main', repository: ['example-org/app', 'example-org/api'] },
19
+ boundClaimsType: 'glob',
20
+ mount: 'jwt-github',
21
+ name: 'ci-app',
22
+ roleType: 'jwt',
23
+ tokenMaxTtl: '30m',
24
+ tokenPolicies: ['ci-app-deploy'],
25
+ tokenTtl: '10m',
26
+ userClaim: 'repository',
27
+ };
28
+
29
+ /** What OpenBao 2.6.2 answers for CI after the write (path_role.go pathRoleRead + tokenutil). */
30
+ const LIVE: Record<string, unknown> = {
31
+ allowed_redirect_uris: null,
32
+ bound_audiences: ['https://github.com/example-org'],
33
+ bound_claims: { ref: 'refs/heads/main', repository: ['example-org/api', 'example-org/app'] },
34
+ bound_claims_type: 'glob',
35
+ bound_subject: '',
36
+ callback_mode: 'client',
37
+ claim_mappings: null,
38
+ clock_skew_leeway: 60,
39
+ groups_claim: '',
40
+ oidc_disable_confirmation: false,
41
+ oidc_scopes: null,
42
+ role_type: 'jwt',
43
+ token_bound_cidrs: [],
44
+ token_explicit_max_ttl: 0,
45
+ token_max_ttl: 1800,
46
+ token_no_default_policy: false,
47
+ token_num_uses: 0,
48
+ token_period: 0,
49
+ token_policies: ['ci-app-deploy'],
50
+ token_ttl: 600,
51
+ token_type: 'default',
52
+ user_claim: 'repository',
53
+ verbose_oidc_logging: false,
54
+ };
55
+
56
+ const same = (live: Record<string, unknown>, props = CI) =>
57
+ matches(attributesOf(props, live), props);
58
+
59
+ describe('jwt role form', () => {
60
+ it('reads the role OpenBao stored as the role that was declared', () => {
61
+ assert.equal(same(LIVE), true);
62
+ });
63
+
64
+ it('sends the four fields the server resets when omitted, and maps as objects', () => {
65
+ const body = writeBody(CI);
66
+ assert.equal(body['role_type'], 'jwt');
67
+ assert.equal(body['bound_claims_type'], 'glob');
68
+ assert.equal(body['callback_mode'], 'client');
69
+ assert.equal(body['oidc_disable_confirmation'], false);
70
+ assert.equal(body['verbose_oidc_logging'], false);
71
+ assert.deepEqual(body['bound_claims'], CI.boundClaims);
72
+ assert.deepEqual(body['claim_mappings'], {});
73
+ assert.deepEqual(body['token_policies'], ['ci-app-deploy']);
74
+ });
75
+
76
+ it('sends every token_* field at its default — the write merges, so an omitted one stays stale', () => {
77
+ const bare: BaoJwtRoleProps = {
78
+ boundSubject: 'repo:example-org/app:ref:refs/heads/main',
79
+ name: 'bare',
80
+ roleType: 'jwt',
81
+ tokenPolicies: [],
82
+ userClaim: 'sub',
83
+ };
84
+ const token = Object.fromEntries(
85
+ Object.entries(writeBody(bare)).filter(([key]) => key.startsWith('token_')),
86
+ );
87
+ assert.deepEqual(token, {
88
+ token_bound_cidrs: [],
89
+ token_explicit_max_ttl: '0',
90
+ token_max_ttl: '0',
91
+ token_no_default_policy: false,
92
+ token_num_uses: 0,
93
+ token_period: '0',
94
+ token_policies: [],
95
+ token_ttl: '0',
96
+ token_type: 'default',
97
+ });
98
+ });
99
+
100
+ it('plans update for drift on a managed field, including the two fixed-false ones', () => {
101
+ const drifted: [string, unknown][] = [
102
+ ['verbose_oidc_logging', true],
103
+ ['oidc_disable_confirmation', true],
104
+ ['role_type', 'oidc'],
105
+ ['token_policies', ['ci-app-deploy', 'admin']],
106
+ ['bound_audiences', ['https://github.com/other-org']],
107
+ ['token_ttl', 3600],
108
+ ];
109
+ for (const [field, value] of drifted)
110
+ assert.equal(same({ ...LIVE, [field]: value }), false, field);
111
+ });
112
+
113
+ it('treats a claim value and a one-element list as the same', () => {
114
+ const props = {
115
+ ...CI,
116
+ boundClaims: { ref: ['refs/heads/main'], repository: CI.boundClaims?.['repository'] ?? [] },
117
+ };
118
+ assert.equal(same(LIVE, props), true);
119
+ });
120
+
121
+ it('refuses what the server would refuse, before any call', () => {
122
+ assert.deepEqual(problems(CI), []);
123
+ const loose = { ...CI, boundAudiences: [], boundClaims: {} };
124
+ assert.match(problems(loose).join(), /bound constraint/);
125
+ assert.match(problems({ ...CI, roleType: 'oidc' }).join(), /allowedRedirectUris/);
126
+ assert.match(problems({ ...CI, name: 'CI-App' }).join(), /lower case/);
127
+ assert.match(problems({ ...CI, claimMappings: { email: 'role' } }).join(), /reserved/);
128
+ assert.match(problems({ ...CI, tokenTtl: '2h' }).join(), /longer than tokenMaxTtl/);
129
+ });
130
+ });