@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,119 @@
1
+ /**
2
+ * Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
3
+ * plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
4
+ * real server does. That is the point: a rename test is only as good as the fake's idea of "the
5
+ * same object".
6
+ *
7
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
+ */
9
+ import * as Effect from 'effect/Effect';
10
+ import * as FileSystem from 'effect/FileSystem';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as Path from 'effect/Path';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+ import type { Reply, Seen } from './fake-bao.ts';
15
+ import { parseDuration } from './mount-form.ts';
16
+ import { trimTrailing } from './mount-path.ts';
17
+ import { BaoPolicyProvider } from './policy.ts';
18
+ import { policyKey } from './rename.ts';
19
+
20
+ type Stored = Record<string, unknown>;
21
+
22
+ /** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
23
+ export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
24
+
25
+ const ABSENT: Reply = { json: { errors: [] }, status: 404 };
26
+
27
+ const store = (
28
+ write: (path: string, body: Record<string, string>, before: Stored | undefined) => Stored,
29
+ key: (path: string) => string = (path) => path,
30
+ ): Store => {
31
+ const live = new Map<string, Stored>();
32
+ const answer = (seen: Seen): Reply => {
33
+ const path = key(seen.path.replace(/^\/v1\//, ''));
34
+ if (seen.method === 'PUT') {
35
+ const body = JSON.parse(seen.body) as Record<string, string>;
36
+ live.set(path, write(path, body, live.get(path)));
37
+ return { status: 204 };
38
+ }
39
+ // ★ A delete answers 204 whether or not the object existed, as all three do.
40
+ if (seen.method === 'DELETE') {
41
+ live.delete(path);
42
+ return { status: 204 };
43
+ }
44
+ const data = live.get(path);
45
+ return data === undefined ? ABSENT : { json: { data }, status: 200 };
46
+ };
47
+ return Object.assign(answer, { live });
48
+ };
49
+
50
+ const lastSegment = (path: string) => path.split('/').at(-1) ?? '';
51
+
52
+ /**
53
+ * `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
54
+ * server — digest.ts), and keys the name as OpenBao does: trimmed and lowercased (`policyKey`).
55
+ */
56
+ export const aclPolicies = (): Store =>
57
+ store(
58
+ (path, body) => ({
59
+ name: lastSegment(path),
60
+ policy: trimTrailing(body['policy'] ?? '', '\n'),
61
+ }),
62
+ (path) =>
63
+ path.startsWith('sys/policies/acl/')
64
+ ? `sys/policies/acl/${policyKey(decodeURIComponent(lastSegment(path)))}`
65
+ : path,
66
+ );
67
+
68
+ /**
69
+ * `<mount>/roles/<name>` on the Cloudflare engine. A write parses the duration text into seconds and
70
+ * re-marshals `policies` (path_roles.go:117-146, :199-210). Names are stored verbatim.
71
+ */
72
+ export const cloudflareRoles = (): Store =>
73
+ store((path, body) => ({
74
+ description: body['description'],
75
+ max_ttl: parseDuration(body['max_ttl'] ?? ''),
76
+ name: lastSegment(path),
77
+ // ★ Re-marshalled, as json.Marshal of the stored structs would be.
78
+ policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
79
+ ttl: parseDuration(body['ttl'] ?? ''),
80
+ }));
81
+
82
+ /**
83
+ * `<mount>/roles/<name>` on the proxmox engine. A write MERGES into the stored role, and TTLs go over
84
+ * as seconds (homeflare-openbao-plugins secrets/proxmox/path_roles.go, pathRolesWrite).
85
+ */
86
+ export const proxmoxRoles = (): Store =>
87
+ store((path, body, before) => ({
88
+ max_ttl: 0,
89
+ name: lastSegment(path),
90
+ ttl: 0,
91
+ ...before,
92
+ ...(body['mint_user'] === undefined ? {} : { mint_user: body['mint_user'] }),
93
+ ...(body['ttl'] === undefined ? {} : { ttl: Number(body['ttl']) }),
94
+ ...(body['max_ttl'] === undefined ? {} : { max_ttl: Number(body['max_ttl']) }),
95
+ }));
96
+
97
+ /**
98
+ * Bao.Policy's provider over fragment directories that exist only here: `…/empty` holds no file,
99
+ * `…/two` holds two, and any other directory one. Each file is one grant named after its own path,
100
+ * so a directory's content is its name.
101
+ */
102
+ export const fakePolicyProviders = Layer.mergeAll(
103
+ BaoPolicyProvider().pipe(
104
+ Layer.provide(
105
+ Layer.mergeAll(
106
+ FileSystem.layerNoop({
107
+ readDirectory: (dir) =>
108
+ Effect.succeed(
109
+ dir.endsWith('/empty') ? [] : dir.endsWith('/two') ? ['a.hcl', 'b.hcl'] : ['a.hcl'],
110
+ ),
111
+ readFileString: (path) =>
112
+ Effect.succeed(`path "${path}" {\n capabilities = ["read"]\n}`),
113
+ }),
114
+ Path.layer,
115
+ ),
116
+ ),
117
+ ),
118
+ FetchHttpClient.layer,
119
+ );
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Alchemy's own Plan and Apply, run in-process over an in-memory state store, so a test can deploy a
3
+ * declaration, change it, deploy again, and see what the ENGINE did with a provider's answers: which
4
+ * action it planned, and which calls reached the fake.
5
+ *
6
+ * ★ WHY THE ENGINE AND NOT THE HANDLERS ALONE. A diff that answers `replace` proves nothing by itself.
7
+ * What matters is that Apply then creates the new generation, deletes the OLD one with the OLD
8
+ * attributes, and keeps it under `retain`. Those are claims about alchemy@2.0.0-beta.79, and this
9
+ * harness measures them instead of restating them (REPLACE.md).
10
+ * ★ NOT alchemy/Test/Bun. Its runtime builds the CLI's profile and credentials stores, which read
11
+ * files under the home directory, plus a file logger. This needs none of that: the providers, an
12
+ * in-memory state store, a stage, and the stack's own services. It is the composition
13
+ * alchemy/src/Deploy.ts uses, minus the platform layer.
14
+ *
15
+ * ⛔ TEST-ONLY, AND NEVER A REAL VAULT. `BaoEnv` is provided explicitly, so a BAO_ADDR or BAO_TOKEN
16
+ * in the shell running the tests is never read. No provider imports this file.
17
+ */
18
+ import * as Alchemy from 'alchemy';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
21
+ import type { BaoEnvironment } from './bao-address.ts';
22
+ import { BaoEnv } from './bao-http.ts';
23
+ import { type Fake, type Reply, type Seen, fakeBao } from './fake-bao.ts';
24
+
25
+ /** The planned action for each resource, deletions included, keyed by FQN. */
26
+ export type Planned = Readonly<Record<string, string>>;
27
+
28
+ /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
29
+ export type StackBody = Effect.Effect<unknown, unknown, unknown>;
30
+
31
+ export interface FakeStack {
32
+ /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
33
+ readonly deploy: (body: StackBody) => Promise<Planned>;
34
+ }
35
+
36
+ type Node = { readonly action: string };
37
+ type PlanView = {
38
+ readonly resources: Readonly<Record<string, Node>>;
39
+ readonly deletions: Readonly<Record<string, Node | undefined>>;
40
+ };
41
+
42
+ const actionsOf = (plan: PlanView): Planned => {
43
+ const out: Record<string, string> = {};
44
+ for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
45
+ for (const [fqn, node] of Object.entries(plan.deletions)) {
46
+ if (node !== undefined) out[fqn] = node.action;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /**
52
+ * One stack over one state store, shared by every `deploy`, so the second deploy plans against what
53
+ * the first one recorded.
54
+ *
55
+ * ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY, NOT IN THE PROVIDERS. `Alchemy.Stack` types its body
56
+ * against every service a CLI run provides. Here the body needs only the providers given, and a
57
+ * missing one fails the test by name ("Provider not found for …").
58
+ */
59
+ export const fakeStack = <ROut, E, RIn>(
60
+ providers: Layer.Layer<ROut, E, RIn>,
61
+ env: BaoEnvironment,
62
+ name = 'RenameStack',
63
+ ): FakeStack => {
64
+ const state = Alchemy.inMemoryState();
65
+ const stack = Alchemy.Stack as unknown as (
66
+ stackName: string,
67
+ options: { providers: typeof providers; state: typeof state },
68
+ body: StackBody,
69
+ ) => Effect.Effect<{ readonly services: never }, unknown, never>;
70
+ const plan = Alchemy.Plan.make as unknown as (
71
+ compiled: unknown,
72
+ ) => Effect.Effect<PlanView, unknown, never>;
73
+ const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
74
+
75
+ const deploy = (body: StackBody) =>
76
+ Effect.gen(function* () {
77
+ const compiled = yield* stack(name, { providers, state }, body);
78
+ return yield* Effect.gen(function* () {
79
+ const planned = yield* plan(compiled);
80
+ yield* apply(planned);
81
+ return actionsOf(planned);
82
+ }).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
83
+ }).pipe(
84
+ Effect.provideService(Alchemy.Stage, 'test'),
85
+ Effect.provideService(BaoEnv, env),
86
+ Effect.provide(state),
87
+ Effect.scoped,
88
+ );
89
+
90
+ return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
91
+ };
92
+
93
+ /**
94
+ * A fake engine answering with `answer`, and one stack over it, for one test. The fake stops however
95
+ * `body` ends.
96
+ */
97
+ export const withFakeStack = async <ROut, E, RIn>(
98
+ providers: Layer.Layer<ROut, E, RIn>,
99
+ answer: (seen: Seen) => Reply,
100
+ body: (stack: FakeStack, bao: Fake) => Promise<void>,
101
+ ): Promise<void> => {
102
+ const bao = fakeBao(answer);
103
+ try {
104
+ await body(fakeStack(providers, { BAO_ADDR: bao.address }), bao);
105
+ } finally {
106
+ bao.stop();
107
+ }
108
+ };
109
+
110
+ /** Every call that changed something, as `METHOD /v1/path`, in order. */
111
+ export const writesOf = (seen: readonly Seen[]): string[] =>
112
+ seen.filter((each) => each.method !== 'GET').map((each) => `${each.method} ${each.path}`);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * hostAppRoles: one role per host, named `<class>--<host>`, and never a secret_id that does not
3
+ * expire. The hosts and classes here are placeholders, not the estate's.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import {
8
+ HOST_ROLE_SEPARATOR,
9
+ type HostRoleClass,
10
+ hostAppRoles,
11
+ hostRoleName,
12
+ } from './host-approles.ts';
13
+ import { parseDuration } from './mount-form.ts';
14
+
15
+ const INFRA: HostRoleClass = {
16
+ policies: ['host-cert', 'pve-node', 'host-cert'],
17
+ secretIdTtl: '2160h',
18
+ tokenMaxTtl: '1h',
19
+ tokenTtl: '15m',
20
+ };
21
+ const SIGNER: HostRoleClass = {
22
+ policies: ['ssh-signer'],
23
+ secretIdTtl: '90d',
24
+ tokenMaxTtl: '30m',
25
+ tokenTtl: '10m',
26
+ };
27
+ const INPUT = {
28
+ classes: { 'operator-signer': SIGNER, 'pve-node': INFRA },
29
+ hosts: [
30
+ { class: 'pve-node', name: 'node-b' },
31
+ { class: 'pve-node', name: 'node-a' },
32
+ { class: 'operator-signer', name: 'workstation-1' },
33
+ ],
34
+ };
35
+
36
+ describe('hostAppRoles', () => {
37
+ it('makes one role per host, named <class>--<host>, sorted', () => {
38
+ const roles = hostAppRoles(INPUT);
39
+ assert.deepEqual(
40
+ roles.map((role) => role.name),
41
+ ['operator-signer--workstation-1', 'pve-node--node-a', 'pve-node--node-b'],
42
+ );
43
+ assert.equal(hostRoleName('pve-node', 'node-a'), 'pve-node--node-a');
44
+ });
45
+
46
+ it('gives every role an expiring secret_id — none has secret_id_ttl 0', () => {
47
+ for (const role of hostAppRoles(INPUT)) {
48
+ const seconds = parseDuration(role.secretIdTtl);
49
+ assert.ok(
50
+ seconds !== undefined && seconds > 0,
51
+ `${role.name} secret_id_ttl ${role.secretIdTtl}`,
52
+ );
53
+ assert.equal(role.bindSecretId, true);
54
+ }
55
+ });
56
+
57
+ it("carries the class's policies, de-duplicated, and its TTLs", () => {
58
+ const role = hostAppRoles(INPUT).find((each) => each.name === 'pve-node--node-a');
59
+ assert.deepEqual(role?.tokenPolicies, ['host-cert', 'pve-node']);
60
+ assert.equal(role?.tokenTtl, '15m');
61
+ assert.equal(role?.secretIdNumUses, 0);
62
+ });
63
+
64
+ it('splits every name back into exactly one class and host', () => {
65
+ for (const role of hostAppRoles(INPUT)) {
66
+ assert.equal(role.name.split(HOST_ROLE_SEPARATOR).length, 2, role.name);
67
+ }
68
+ });
69
+
70
+ it('refuses a secret_id that never expires, or does not parse', () => {
71
+ for (const secretIdTtl of ['0', '0s', 'forever']) {
72
+ assert.throws(
73
+ () =>
74
+ hostAppRoles({
75
+ ...INPUT,
76
+ classes: { ...INPUT.classes, 'pve-node': { ...INFRA, secretIdTtl } },
77
+ }),
78
+ /must be a duration above zero/,
79
+ );
80
+ }
81
+ });
82
+
83
+ it('refuses unknown classes, duplicate hosts and names that would blur the separator', () => {
84
+ const bad = [
85
+ { class: 'nope', name: 'node-c' },
86
+ { class: 'pve-node', name: 'node-a' },
87
+ { class: 'pve-node', name: 'node-a' },
88
+ { class: 'pve-node', name: 'node--x' },
89
+ { class: 'pve-node', name: 'Node-Y' },
90
+ ];
91
+ assert.throws(
92
+ () => hostAppRoles({ ...INPUT, hosts: bad }),
93
+ (error: unknown) => {
94
+ assert.ok(error instanceof Error);
95
+ assert.match(error.message, /unknown class `nope`/);
96
+ assert.match(error.message, /`node-a` is listed twice/);
97
+ assert.match(error.message, /`node--x` is not lowercase/);
98
+ assert.match(error.message, /`Node-Y` is not lowercase/);
99
+ return true;
100
+ },
101
+ );
102
+ });
103
+
104
+ it('refuses a class with no policies, with root, or with ttl above max', () => {
105
+ const classes = {
106
+ empty: { ...INFRA, policies: [] },
107
+ rooted: { ...INFRA, policies: ['root'] },
108
+ upside: { ...INFRA, tokenMaxTtl: '5m', tokenTtl: '1h' },
109
+ };
110
+ assert.throws(
111
+ () => hostAppRoles({ classes, hosts: [] }),
112
+ (error: unknown) => {
113
+ assert.ok(error instanceof Error);
114
+ assert.match(error.message, /`empty` has no policies/);
115
+ assert.match(error.message, /`rooted` grants `root`/);
116
+ assert.match(error.message, /`upside` tokenTtl is longer/);
117
+ return true;
118
+ },
119
+ );
120
+ });
121
+ });
@@ -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
+ };