@homeflare/alchemy 0.4.1 → 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 (215) hide show
  1. package/README.md +26 -0
  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/approle-login-form.d.ts +72 -0
  43. package/dist/openbao/approle-login-form.d.ts.map +1 -0
  44. package/dist/openbao/approle-login-result.d.ts +39 -0
  45. package/dist/openbao/approle-login-result.d.ts.map +1 -0
  46. package/dist/openbao/approle-login.d.ts +77 -0
  47. package/dist/openbao/approle-login.d.ts.map +1 -0
  48. package/dist/openbao/auth-method-form.d.ts +7 -0
  49. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  51. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  52. package/dist/openbao/auth-method.d.ts +6 -0
  53. package/dist/openbao/auth-method.d.ts.map +1 -1
  54. package/dist/openbao/auth-role-form.d.ts +9 -0
  55. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  56. package/dist/openbao/auth-role.d.ts.map +1 -1
  57. package/dist/openbao/auth-token-form.d.ts +48 -0
  58. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  59. package/dist/openbao/bao-http.d.ts +2 -0
  60. package/dist/openbao/bao-http.d.ts.map +1 -1
  61. package/dist/openbao/bao-identity.d.ts +81 -0
  62. package/dist/openbao/bao-identity.d.ts.map +1 -0
  63. package/dist/openbao/bao-namespace.d.ts +20 -0
  64. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  66. package/dist/openbao/host-approles.d.ts +62 -0
  67. package/dist/openbao/host-approles.d.ts.map +1 -0
  68. package/dist/openbao/index.d.ts +26 -0
  69. package/dist/openbao/index.d.ts.map +1 -1
  70. package/dist/openbao/index.js +1889 -477
  71. package/dist/openbao/index.js.map +46 -17
  72. package/dist/openbao/jwt-config-form.d.ts +51 -0
  73. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  74. package/dist/openbao/jwt-config.d.ts +25 -0
  75. package/dist/openbao/jwt-config.d.ts.map +1 -0
  76. package/dist/openbao/jwt-role-form.d.ts +91 -0
  77. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  78. package/dist/openbao/jwt-role.d.ts +31 -0
  79. package/dist/openbao/jwt-role.d.ts.map +1 -0
  80. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  81. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  82. package/dist/openbao/kubernetes-role.d.ts +25 -0
  83. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  84. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  85. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  86. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  88. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  89. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  90. package/dist/openbao/mfa-totp.d.ts +33 -0
  91. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  92. package/dist/openbao/mfa-wire.d.ts +63 -0
  93. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  94. package/dist/openbao/mount-form.d.ts +7 -0
  95. package/dist/openbao/mount-form.d.ts.map +1 -1
  96. package/dist/openbao/mount-move.d.ts +49 -0
  97. package/dist/openbao/mount-move.d.ts.map +1 -0
  98. package/dist/openbao/mount-path.d.ts +18 -0
  99. package/dist/openbao/mount-path.d.ts.map +1 -0
  100. package/dist/openbao/mount-reconcile.d.ts +16 -0
  101. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  102. package/dist/openbao/mount.d.ts +9 -0
  103. package/dist/openbao/mount.d.ts.map +1 -1
  104. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  105. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  106. package/dist/openbao/pki-role-form.d.ts +38 -30
  107. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  108. package/dist/openbao/pki-role.d.ts.map +1 -1
  109. package/dist/openbao/plugin-form.d.ts +60 -0
  110. package/dist/openbao/plugin-form.d.ts.map +1 -0
  111. package/dist/openbao/plugin-reconcile.d.ts +18 -0
  112. package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
  113. package/dist/openbao/plugin-wire.d.ts +45 -0
  114. package/dist/openbao/plugin-wire.d.ts.map +1 -0
  115. package/dist/openbao/plugin.d.ts +31 -0
  116. package/dist/openbao/plugin.d.ts.map +1 -0
  117. package/dist/openbao/remount-wire.d.ts +44 -0
  118. package/dist/openbao/remount-wire.d.ts.map +1 -0
  119. package/dist/openbao/role-reconcile.d.ts +40 -0
  120. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  121. package/dist/openbao/ssh-role-form.d.ts +6 -5
  122. package/dist/openbao/ssh-role-form.d.ts.map +1 -1
  123. package/dist/openbao/ssh-role.d.ts.map +1 -1
  124. package/docs/launchd.md +167 -0
  125. package/package.json +7 -2
  126. package/src/index.ts +1 -0
  127. package/src/launchd/fake-runner.ts +176 -0
  128. package/src/launchd/host-effect.ts +28 -0
  129. package/src/launchd/host-file-form.ts +71 -0
  130. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  131. package/src/launchd/host-file-lifecycle.ts +164 -0
  132. package/src/launchd/host-file.ts +64 -0
  133. package/src/launchd/host-lookup.test.ts +92 -0
  134. package/src/launchd/host-lookup.ts +94 -0
  135. package/src/launchd/index.ts +26 -0
  136. package/src/launchd/job-form.test.ts +171 -0
  137. package/src/launchd/job-form.ts +180 -0
  138. package/src/launchd/job-identity.test.ts +147 -0
  139. package/src/launchd/job-lifecycle.test.ts +220 -0
  140. package/src/launchd/job-lifecycle.ts +194 -0
  141. package/src/launchd/job-preflight.ts +152 -0
  142. package/src/launchd/job-validate.ts +134 -0
  143. package/src/launchd/job.ts +96 -0
  144. package/src/launchd/launchctl.test.ts +181 -0
  145. package/src/launchd/launchctl.ts +188 -0
  146. package/src/launchd/local-runner.test.ts +125 -0
  147. package/src/launchd/local-runner.ts +143 -0
  148. package/src/launchd/plist.test.ts +186 -0
  149. package/src/launchd/plist.ts +156 -0
  150. package/src/launchd/providers.test.ts +222 -0
  151. package/src/launchd/providers.ts +21 -0
  152. package/src/launchd/runner.ts +95 -0
  153. package/src/launchd/secret-tripwire.test.ts +110 -0
  154. package/src/launchd/secret-tripwire.ts +81 -0
  155. package/src/openbao/README.md +181 -0
  156. package/src/openbao/REPLACE.md +87 -0
  157. package/src/openbao/approle-login-form.test.ts +64 -0
  158. package/src/openbao/approle-login-form.ts +153 -0
  159. package/src/openbao/approle-login-result.ts +96 -0
  160. package/src/openbao/approle-login.test.ts +200 -0
  161. package/src/openbao/approle-login.ts +156 -0
  162. package/src/openbao/auth-method-form.ts +7 -0
  163. package/src/openbao/auth-method-reconcile.ts +74 -0
  164. package/src/openbao/auth-method.ts +20 -41
  165. package/src/openbao/auth-role-form.test.ts +57 -0
  166. package/src/openbao/auth-role-form.ts +11 -0
  167. package/src/openbao/auth-role.ts +12 -0
  168. package/src/openbao/auth-token-form.ts +161 -0
  169. package/src/openbao/bao-http.ts +2 -0
  170. package/src/openbao/bao-identity.test.ts +131 -0
  171. package/src/openbao/bao-identity.ts +188 -0
  172. package/src/openbao/bao-namespace.ts +32 -0
  173. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  174. package/src/openbao/host-approles.test.ts +121 -0
  175. package/src/openbao/host-approles.ts +126 -0
  176. package/src/openbao/index.ts +32 -0
  177. package/src/openbao/jwt-config-form.ts +161 -0
  178. package/src/openbao/jwt-config.ts +85 -0
  179. package/src/openbao/jwt-role-form.test.ts +130 -0
  180. package/src/openbao/jwt-role-form.ts +231 -0
  181. package/src/openbao/jwt-role.ts +102 -0
  182. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  183. package/src/openbao/kubernetes-role-form.ts +149 -0
  184. package/src/openbao/kubernetes-role.ts +94 -0
  185. package/src/openbao/mfa-enforcement-form.ts +147 -0
  186. package/src/openbao/mfa-enforcement.ts +143 -0
  187. package/src/openbao/mfa-totp-form.ts +155 -0
  188. package/src/openbao/mfa-totp.ts +118 -0
  189. package/src/openbao/mfa-wire.ts +117 -0
  190. package/src/openbao/mfa.test.ts +200 -0
  191. package/src/openbao/mount-form.ts +9 -1
  192. package/src/openbao/mount-move.test.ts +164 -0
  193. package/src/openbao/mount-move.ts +113 -0
  194. package/src/openbao/mount-path.test.ts +33 -0
  195. package/src/openbao/mount-path.ts +31 -0
  196. package/src/openbao/mount-reconcile.ts +75 -0
  197. package/src/openbao/mount-wire.test.ts +13 -0
  198. package/src/openbao/mount.ts +26 -43
  199. package/src/openbao/pki-role-canonical.ts +163 -0
  200. package/src/openbao/pki-role-form.test.ts +112 -0
  201. package/src/openbao/pki-role-form.ts +76 -110
  202. package/src/openbao/pki-role.ts +12 -27
  203. package/src/openbao/plugin-form.test.ts +86 -0
  204. package/src/openbao/plugin-form.ts +164 -0
  205. package/src/openbao/plugin-reconcile.ts +92 -0
  206. package/src/openbao/plugin-wire.test.ts +146 -0
  207. package/src/openbao/plugin-wire.ts +65 -0
  208. package/src/openbao/plugin.ts +100 -0
  209. package/src/openbao/remount-wire.test.ts +87 -0
  210. package/src/openbao/remount-wire.ts +112 -0
  211. package/src/openbao/role-reconcile.test.ts +104 -0
  212. package/src/openbao/role-reconcile.ts +84 -0
  213. package/src/openbao/ssh-role-form.test.ts +105 -0
  214. package/src/openbao/ssh-role-form.ts +18 -16
  215. package/src/openbao/ssh-role.ts +3 -4
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The form half of Bao.MfaLoginEnforcement — `identity/mfa/login-enforcement/<name>`: which logins
3
+ * must pass which MFA methods.
4
+ *
5
+ * ★ READ FROM openbao v2.6.2 vault/identity/mfa.go handleMFALoginEnforcementUpdate (:511-646):
6
+ * · `mfa_method_ids` is required, and each id must exist in this namespace or a parent.
7
+ * · The four target lists are each `GetOk`: an omitted list KEEPS its stored value, and ANY list
8
+ * that is present — even empty — satisfies "one of … must be specified" (:634-636). So every
9
+ * list is sent on every write (a stale target is cleared), and "all four empty" — which the
10
+ * server would accept, and which enforces NOTHING — is refused here.
11
+ * · Every field is a TypeStringSlice (store.go:751-772). ⚠️ A comma string there is ONE element
12
+ * (sdk framework/field_data.go:343-356 WeakDecodes a string into a one-item slice), so lists
13
+ * go over as JSON arrays.
14
+ * ⚠️ A TYPE TARGET IS BROAD. `authMethodTypes: ['jwt']` matches EVERY jwt-type mount — including a
15
+ * GitHub Actions or Kubernetes issuer mount, whose logins cannot answer a TOTP prompt. Target
16
+ * mounts by path (resolved to accessors) or by identity group instead.
17
+ */
18
+ import { sha256 } from './digest.ts';
19
+ import { mountPath } from './mount-form.ts';
20
+
21
+ export interface BaoMfaLoginEnforcementProps {
22
+ /** Enforcement name, unique per namespace. */
23
+ name: string;
24
+ /** Method ids — typically `[totp.methodId]`. Any ONE listed method passing is enough. */
25
+ mfaMethodIds: readonly string[];
26
+ /**
27
+ * Auth mount PATHS (`oidc`, `jwt`), resolved to their accessors at plan and deploy time.
28
+ * ⚠️ A plain string makes no graph edge. When the mount is a BaoAuthMethod in the same stack, pass
29
+ * its output (`[oidc.path]`) so it exists before this resolves — otherwise the deploy can reach
30
+ * this first and refuse with "no auth mount at …".
31
+ */
32
+ authMethodPaths?: readonly string[];
33
+ /** Raw accessors (`auth_jwt_…`), for a mount outside this stack. */
34
+ authMethodAccessors?: readonly string[];
35
+ /** Auth mount TYPES — read the ⚠️ above first. */
36
+ authMethodTypes?: readonly string[];
37
+ /** Identity group ids whose members must pass MFA, on any mount. */
38
+ identityGroupIds?: readonly string[];
39
+ /** Identity entity ids, likewise. */
40
+ identityEntityIds?: readonly string[];
41
+ }
42
+
43
+ /** The live shape, and the declared one once paths are resolved to accessors. */
44
+ export interface BaoMfaEnforcementCanonical {
45
+ authMethodAccessors: readonly string[];
46
+ authMethodTypes: readonly string[];
47
+ identityEntityIds: readonly string[];
48
+ identityGroupIds: readonly string[];
49
+ mfaMethodIds: readonly string[];
50
+ }
51
+
52
+ /** ⛔ No secret — ids, accessors and names. */
53
+ export interface BaoMfaLoginEnforcementAttributes extends BaoMfaEnforcementCanonical {
54
+ name: string;
55
+ /** The server-made id. */
56
+ enforcementId: string;
57
+ namespacePath: string;
58
+ digest: string;
59
+ }
60
+
61
+ const sortedSet = (values: readonly string[]) =>
62
+ [...new Set(values.map((value) => value.trim()))].filter((value) => value !== '').sort();
63
+ const strings = (value: unknown): readonly string[] =>
64
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
65
+ const text = (value: unknown) => (typeof value === 'string' ? value : '');
66
+
67
+ const canonical = (input: BaoMfaEnforcementCanonical): BaoMfaEnforcementCanonical => ({
68
+ authMethodAccessors: sortedSet(input.authMethodAccessors),
69
+ authMethodTypes: sortedSet(input.authMethodTypes),
70
+ identityEntityIds: sortedSet(input.identityEntityIds),
71
+ identityGroupIds: sortedSet(input.identityGroupIds),
72
+ mfaMethodIds: sortedSet(input.mfaMethodIds),
73
+ });
74
+
75
+ /** The declaration, with `authMethodPaths` already resolved into `resolved` accessors. */
76
+ export const canonicalFromProps = (
77
+ props: BaoMfaLoginEnforcementProps,
78
+ resolved: readonly string[],
79
+ ): BaoMfaEnforcementCanonical =>
80
+ canonical({
81
+ authMethodAccessors: [...(props.authMethodAccessors ?? []), ...resolved],
82
+ authMethodTypes: props.authMethodTypes ?? [],
83
+ identityEntityIds: props.identityEntityIds ?? [],
84
+ identityGroupIds: props.identityGroupIds ?? [],
85
+ mfaMethodIds: props.mfaMethodIds,
86
+ });
87
+
88
+ export const canonicalFromLive = (live: Record<string, unknown>): BaoMfaEnforcementCanonical =>
89
+ canonical({
90
+ authMethodAccessors: strings(live['auth_method_accessors']),
91
+ authMethodTypes: strings(live['auth_method_types']),
92
+ identityEntityIds: strings(live['identity_entity_ids']),
93
+ identityGroupIds: strings(live['identity_group_ids']),
94
+ mfaMethodIds: strings(live['mfa_method_ids']),
95
+ });
96
+
97
+ export const attributesOf = (
98
+ name: string,
99
+ live: Record<string, unknown>,
100
+ ): BaoMfaLoginEnforcementAttributes => {
101
+ const form = canonicalFromLive(live);
102
+ return {
103
+ ...form,
104
+ digest: sha256(JSON.stringify(form)),
105
+ enforcementId: text(live['id']),
106
+ name,
107
+ namespacePath: text(live['namespace_path']),
108
+ };
109
+ };
110
+
111
+ export const matches = (
112
+ attributes: BaoMfaLoginEnforcementAttributes,
113
+ props: BaoMfaLoginEnforcementProps,
114
+ resolved: readonly string[],
115
+ ): boolean => attributes.digest === sha256(JSON.stringify(canonicalFromProps(props, resolved)));
116
+
117
+ /** Every list, as a JSON array, every time — the ⚠️ on GetOk above. */
118
+ export const writeBody = (form: BaoMfaEnforcementCanonical): Record<string, unknown> => ({
119
+ auth_method_accessors: [...form.authMethodAccessors],
120
+ auth_method_types: [...form.authMethodTypes],
121
+ identity_entity_ids: [...form.identityEntityIds],
122
+ identity_group_ids: [...form.identityGroupIds],
123
+ mfa_method_ids: [...form.mfaMethodIds],
124
+ });
125
+
126
+ const NAME = /^\w(?:[\w.-]*\w)?$/;
127
+
128
+ export const problems = (
129
+ props: BaoMfaLoginEnforcementProps,
130
+ missingPaths: readonly string[],
131
+ ): readonly string[] => {
132
+ const found: string[] = [];
133
+ if (!NAME.test(props.name)) found.push(`name \`${props.name}\` is not a valid enforcement name`);
134
+ if (sortedSet(props.mfaMethodIds).length === 0) found.push('mfaMethodIds is empty');
135
+ const targets = [
136
+ props.authMethodPaths,
137
+ props.authMethodAccessors,
138
+ props.authMethodTypes,
139
+ props.identityGroupIds,
140
+ props.identityEntityIds,
141
+ ].some((list) => sortedSet(list ?? []).length > 0);
142
+ if (!targets) found.push('no target: an enforcement with none is accepted and enforces NOTHING');
143
+ if (missingPaths.length > 0) {
144
+ found.push(`no auth mount at ${missingPaths.map((path) => mountPath(path)).join(', ')}`);
145
+ }
146
+ return found;
147
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A login-MFA enforcement — `identity/mfa/login-enforcement/<name>`: logins through these mounts
3
+ * (or by these groups/entities) must also pass one of these MFA methods. The machine-access plan's
4
+ * "admin requires OpenBao login MFA" is this resource.
5
+ *
6
+ * ⛔ ENROL BEFORE YOU ENFORCE. A login the enforcement matches, by an entity with no secret for any
7
+ * listed method, FAILS (login_mfa.go validateLoginMFAInternal: "MFA secret … not present"). Enrol
8
+ * every admin (`identity/mfa/method/totp/admin-generate`, which RETURNS the secret — never through
9
+ * Alchemy) before the first deploy of this, or the admin lane locks itself out.
10
+ * ⚠️ IT MATCHES MOUNTS, GROUPS AND ENTITIES — NEVER ROLES. An enforcement on the `jwt/` accessor
11
+ * covers every role on that mount (login_mfa.go buildMFAEnforcementConfigList :932-1017). To put
12
+ * MFA on admin only, give admin its own auth mount, or target an identity group of admins.
13
+ * ⛔ THE DELETE IS REFUSED: IN 2.6.2 IT DOES NOT STICK. DeleteMFALoginEnforcementConfigByNameAndNamespace
14
+ * (login_mfa.go:1879-1902) removes the entry from memory and never from storage, so it RETURNS on the
15
+ * next unseal — tracked upstream as openbao/openbao#4030, open on 2026-09-21. Worse, a method
16
+ * deleted in between leaves the resurrected enforcement naming an id that no longer exists, and
17
+ * every login it matches then fails ("MFA method configuration not present", :871-877). A delete
18
+ * that reports success and quietly comes back is the lie this package refuses to tell. Under the
19
+ * default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
20
+ * live — which is what the server would do anyway.
21
+ *
22
+ * ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first (names are per
23
+ * namespace; the new one cannot collide). Under `retain` BOTH enforcements then apply — fail
24
+ * closed. Under `destroy` the old one's delete is refused, so the deploy stops after creating.
25
+ */
26
+ import { Resource } from 'alchemy';
27
+ import { isResolved } from 'alchemy/Diff';
28
+ import * as Provider from 'alchemy/Provider';
29
+ import * as Effect from 'effect/Effect';
30
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
31
+ import type { BaoError } from './bao-status.ts';
32
+ import {
33
+ type BaoMfaLoginEnforcementAttributes,
34
+ type BaoMfaLoginEnforcementProps,
35
+ attributesOf,
36
+ canonicalFromProps,
37
+ matches,
38
+ problems,
39
+ writeBody,
40
+ } from './mfa-enforcement-form.ts';
41
+ import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
42
+
43
+ export type {
44
+ BaoMfaLoginEnforcementAttributes,
45
+ BaoMfaLoginEnforcementProps,
46
+ } from './mfa-enforcement-form.ts';
47
+
48
+ export interface BaoMfaLoginEnforcement extends Resource<
49
+ 'Bao.MfaLoginEnforcement',
50
+ BaoMfaLoginEnforcementProps,
51
+ BaoMfaLoginEnforcementAttributes,
52
+ never,
53
+ HttpClient.HttpClient
54
+ > {}
55
+
56
+ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaLoginEnforcement', {
57
+ defaultRemovalPolicy: 'retain',
58
+ });
59
+
60
+ type Env = HttpClient.HttpClient;
61
+
62
+ const refuse = (name: string, message: string): Effect.Effect<never> =>
63
+ Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
64
+
65
+ /** Paths resolved to accessors, and what the declaration would be refused for. */
66
+ const resolveTargets = (props: BaoMfaLoginEnforcementProps) =>
67
+ Effect.map(authAccessors(props.authMethodPaths ?? []), ({ accessors, missing }) => ({
68
+ bad: problems(props, missing),
69
+ resolved: accessors,
70
+ }));
71
+
72
+ const readLive = (
73
+ name: string,
74
+ ): Effect.Effect<BaoMfaLoginEnforcementAttributes | undefined, BaoError, Env> =>
75
+ Effect.map(readEnforcement(name), (live) =>
76
+ live === undefined ? undefined : attributesOf(name, live),
77
+ );
78
+
79
+ export const planEnforcement = (
80
+ props: BaoMfaLoginEnforcementProps,
81
+ ): Effect.Effect<'noop' | 'update', BaoError, Env> =>
82
+ Effect.gen(function* () {
83
+ const { bad, resolved } = yield* resolveTargets(props);
84
+ if (bad.length > 0) return 'update';
85
+ const live = yield* readLive(props.name);
86
+ return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
87
+ });
88
+
89
+ /** Write when live differs, then prove it by reading back. */
90
+ export const reconcileEnforcement = (
91
+ props: BaoMfaLoginEnforcementProps,
92
+ ): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
93
+ Effect.gen(function* () {
94
+ const { bad, resolved } = yield* resolveTargets(props);
95
+ if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
96
+ const live = yield* readLive(props.name);
97
+ if (live === undefined || !matches(live, props, resolved)) {
98
+ yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
99
+ }
100
+ const after = yield* readLive(props.name);
101
+ if (after === undefined)
102
+ return yield* refuse(props.name, 'the write succeeded but it is absent.');
103
+ if (!matches(after, props, resolved)) {
104
+ return yield* refuse(props.name, 'the enforcement read back different.');
105
+ }
106
+ return after;
107
+ });
108
+
109
+ export const BaoMfaLoginEnforcementProvider = () =>
110
+ Provider.effect(
111
+ BaoMfaLoginEnforcement,
112
+ Effect.succeed(
113
+ BaoMfaLoginEnforcement.Provider.of({
114
+ /** ⛔ The namespace's enforcement listing is not a list of things this owns. */
115
+ list: () => Effect.succeed([]),
116
+
117
+ read: Effect.fn(function* ({ olds }) {
118
+ return yield* readLive(olds.name);
119
+ }),
120
+
121
+ /** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
122
+ diff: Effect.fn(function* ({ news, output }) {
123
+ if (output === undefined || !isResolved(news)) return undefined;
124
+ if (news.name !== output.name) return { action: 'replace' } as const;
125
+ return { action: yield* planEnforcement(news) } as const;
126
+ }),
127
+
128
+ reconcile: Effect.fn(function* ({ news }) {
129
+ return yield* reconcileEnforcement(news);
130
+ }),
131
+
132
+ /** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
133
+ delete: Effect.fn(function* ({ output }) {
134
+ return yield* refuse(
135
+ output.name,
136
+ 'REFUSING to delete: OpenBao 2.6.2 drops a login enforcement from memory only, and it ' +
137
+ 'returns on the next unseal (openbao/openbao#4030). Set this resource back to ' +
138
+ 'retain to drop it from state; remove it for good only once the server is fixed.',
139
+ );
140
+ }),
141
+ }),
142
+ ),
143
+ );
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The form half of Bao.MfaTotpMethod — a TOTP login-MFA method, identified by its `method_name`.
3
+ *
4
+ * ★ READ FROM openbao v2.6.2 vault/identity/store.go:349-427 (fields) and vault/identity/mfa.go
5
+ * parseTOTPConfig (:662-741): `issuer` is required; `algorithm` is SHA1|SHA256|SHA512; `digits`
6
+ * 6|8; `skew` 0|1; `period`, `key_size` above zero; `max_validation_attempts` 0 means the default
7
+ * of 5 (:35). EVERY field is `d.Get`, so a write resets whatever it omits — every one is sent.
8
+ * ★ THE READ (login_mfa.go mfaConfigToMap :775-829) answers `period` in seconds and `algorithm` as
9
+ * its name; `name`, `id` and `namespace_path` ride along.
10
+ * ⚠️ A CHANGED ALGORITHM, DIGITS OR PERIOD APPLIES TO NEW ENROLMENTS ONLY. Validation uses the
11
+ * parameters stored with each entity's own secret (login_mfa.go validateTOTP :1520-1545), so an
12
+ * enrolled admin keeps working — with the old parameters — until re-enrolled.
13
+ * ⛔ NO SECRET: the method holds no key. Each entity's TOTP secret is generated per entity by
14
+ * `.../totp/generate` or `admin-generate`, which RETURNS it; this resource calls neither.
15
+ */
16
+ import { sha256 } from './digest.ts';
17
+ import { parseDuration, ttlSeconds } from './mount-form.ts';
18
+
19
+ export type BaoTotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512';
20
+
21
+ export interface BaoMfaTotpMethodProps {
22
+ /** `method_name` — the declarative key, unique per namespace. ⛔ A rename is refused (mfa-totp.ts). */
23
+ name: string;
24
+ /** The issuer an authenticator app shows, e.g. `vault-example`. */
25
+ issuer: string;
26
+ /** Code lifetime. Default `30s`. */
27
+ period?: string;
28
+ /** Secret size in bytes. Default 20. */
29
+ keySize?: number;
30
+ /** QR code size in pixels; `0` for none. Default 200. */
31
+ qrSize?: number;
32
+ /** Default `SHA1` — the one every authenticator app supports. */
33
+ algorithm?: BaoTotpAlgorithm;
34
+ /** Default 6. */
35
+ digits?: 6 | 8;
36
+ /** Periods of clock skew accepted. Default 1. */
37
+ skew?: 0 | 1;
38
+ /** Failed codes before the entity is locked out of this method. Default 5. */
39
+ maxValidationAttempts?: number;
40
+ }
41
+
42
+ export interface BaoMfaTotpCanonical {
43
+ algorithm: string;
44
+ digits: number;
45
+ issuer: string;
46
+ keySize: number;
47
+ maxValidationAttempts: number;
48
+ periodSeconds: number;
49
+ qrSize: number;
50
+ skew: number;
51
+ }
52
+
53
+ export interface BaoMfaTotpMethodAttributes extends BaoMfaTotpCanonical {
54
+ /** The server-made UUID — what a Bao.MfaLoginEnforcement names in `mfaMethodIds`. */
55
+ methodId: string;
56
+ name: string;
57
+ /** `''` for root, `team-a/` below it. */
58
+ namespacePath: string;
59
+ digest: string;
60
+ }
61
+
62
+ const canonical = (input: BaoMfaTotpCanonical): BaoMfaTotpCanonical => ({
63
+ algorithm: input.algorithm,
64
+ digits: input.digits,
65
+ issuer: input.issuer,
66
+ keySize: input.keySize,
67
+ maxValidationAttempts: input.maxValidationAttempts,
68
+ periodSeconds: input.periodSeconds,
69
+ qrSize: input.qrSize,
70
+ skew: input.skew,
71
+ });
72
+
73
+ export const canonicalFromProps = (props: BaoMfaTotpMethodProps): BaoMfaTotpCanonical =>
74
+ canonical({
75
+ algorithm: props.algorithm ?? 'SHA1',
76
+ digits: props.digits ?? 6,
77
+ issuer: props.issuer,
78
+ keySize: props.keySize ?? 20,
79
+ maxValidationAttempts: props.maxValidationAttempts ?? 5,
80
+ periodSeconds: parseDuration(props.period ?? '30s') ?? -1,
81
+ qrSize: props.qrSize ?? 200,
82
+ skew: props.skew ?? 1,
83
+ });
84
+
85
+ const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : -1);
86
+ const text = (value: unknown) => (typeof value === 'string' ? value : '');
87
+
88
+ export const canonicalFromLive = (live: Record<string, unknown>): BaoMfaTotpCanonical =>
89
+ canonical({
90
+ algorithm: text(live['algorithm']),
91
+ digits: num(live['digits']),
92
+ issuer: text(live['issuer']),
93
+ keySize: num(live['key_size']),
94
+ maxValidationAttempts: num(live['max_validation_attempts']),
95
+ periodSeconds: ttlSeconds(live['period']) ?? -1,
96
+ qrSize: num(live['qr_size']),
97
+ skew: num(live['skew']),
98
+ });
99
+
100
+ export const attributesOf = (
101
+ methodId: string,
102
+ live: Record<string, unknown>,
103
+ ): BaoMfaTotpMethodAttributes => {
104
+ const form = canonicalFromLive(live);
105
+ return {
106
+ ...form,
107
+ digest: sha256(JSON.stringify(form)),
108
+ methodId,
109
+ name: text(live['name']),
110
+ namespacePath: text(live['namespace_path']),
111
+ };
112
+ };
113
+
114
+ export const matches = (attributes: BaoMfaTotpMethodAttributes, props: BaoMfaTotpMethodProps) =>
115
+ attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
116
+
117
+ /** The upsert body — by `method_name`, never `method_id` (mfa-wire.ts). */
118
+ export const writeBody = (props: BaoMfaTotpMethodProps): Record<string, unknown> => {
119
+ const form = canonicalFromProps(props);
120
+ return {
121
+ algorithm: form.algorithm,
122
+ digits: form.digits,
123
+ issuer: form.issuer,
124
+ key_size: form.keySize,
125
+ max_validation_attempts: form.maxValidationAttempts,
126
+ method_name: props.name,
127
+ period: props.period ?? '30s',
128
+ qr_size: form.qrSize,
129
+ skew: form.skew,
130
+ };
131
+ };
132
+
133
+ /**
134
+ * The diff's refusal for a changed name, or undefined when the name is unchanged. Never a
135
+ * `replace`: the ⛔ on a rename in mfa-totp.ts says why.
136
+ */
137
+ export const renameProblem = (from: string, to: string): string | undefined =>
138
+ from === to
139
+ ? undefined
140
+ : `renaming from \`${from}\` would strand every enrolled secret. Declare the new method as a ` +
141
+ 'second resource, list both ids on the enforcement, enrol, then remove the old one ' +
142
+ '(mfa-totp.ts).';
143
+
144
+ export const problems = (props: BaoMfaTotpMethodProps): readonly string[] => {
145
+ const found: string[] = [];
146
+ const form = canonicalFromProps(props);
147
+ if (props.name.trim() === '') found.push('name is empty; it is the only key a declaration has');
148
+ if (props.issuer.trim() === '') found.push('issuer is empty; the server requires one');
149
+ if (form.periodSeconds <= 0)
150
+ found.push(`period \`${props.period ?? ''}\` must be a duration above zero`);
151
+ if (form.keySize <= 0) found.push('keySize must be above zero');
152
+ if (form.maxValidationAttempts < 1)
153
+ found.push('maxValidationAttempts must be at least 1 (0 reads back as 5)');
154
+ return found;
155
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A TOTP login-MFA method — `identity/mfa/method/totp`, found by `method_name`. The method is
3
+ * policy only: issuer, period, digits. Its `methodId` is what a Bao.MfaLoginEnforcement names.
4
+ *
5
+ * ⛔ A RENAME IS REFUSED, NOT REPLACED. A new name is a new method with a new id, and every entity's
6
+ * enrolled secret belongs to the OLD id (login_mfa.go validateTOTP reads `entity.MFASecrets[id]`).
7
+ * The enforcement would follow the new id in the same deploy, and every enrolled admin would then
8
+ * fail MFA — with nobody left able to log in and re-enrol them. The safe move is additive: declare
9
+ * the new method as a SECOND resource, list both ids on the enforcement (any one method passes,
10
+ * login_mfa.go:831-858), enrol everyone, then drop the old one.
11
+ * ⛔ DELETING THE METHOD DESTROYS EVERY ENROLLED SECRET FOR IT (mfa-wire.ts deleteTotp) — and with
12
+ * openbao/openbao#4030, a deleted enforcement can come back after a restart still naming this id,
13
+ * failing every login it matches. `retain` is the default for exactly that reason.
14
+ *
15
+ * ★ REPLACE SEMANTICS (REPLACE.md): never `replace`. `name` changed → the plan FAILS (above); any
16
+ * other field is an in-place `update`, because the upsert keeps the id.
17
+ */
18
+ import { Resource } from 'alchemy';
19
+ import { isResolved } from 'alchemy/Diff';
20
+ import * as Provider from 'alchemy/Provider';
21
+ import * as Effect from 'effect/Effect';
22
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
23
+ import type { BaoError } from './bao-status.ts';
24
+ import {
25
+ type BaoMfaTotpMethodAttributes,
26
+ type BaoMfaTotpMethodProps,
27
+ attributesOf,
28
+ matches,
29
+ problems,
30
+ renameProblem,
31
+ writeBody,
32
+ } from './mfa-totp-form.ts';
33
+ import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
34
+
35
+ export type {
36
+ BaoMfaTotpMethodAttributes,
37
+ BaoMfaTotpMethodProps,
38
+ BaoTotpAlgorithm,
39
+ } from './mfa-totp-form.ts';
40
+
41
+ export interface BaoMfaTotpMethod extends Resource<
42
+ 'Bao.MfaTotpMethod',
43
+ BaoMfaTotpMethodProps,
44
+ BaoMfaTotpMethodAttributes,
45
+ never,
46
+ HttpClient.HttpClient
47
+ > {}
48
+
49
+ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod', {
50
+ defaultRemovalPolicy: 'retain',
51
+ });
52
+
53
+ const refuse = (props: BaoMfaTotpMethodProps, message: string): Effect.Effect<never> =>
54
+ Effect.die(new Error(`Bao.MfaTotpMethod ${props.name}: ${message}`));
55
+
56
+ export const readTotp = (
57
+ props: BaoMfaTotpMethodProps,
58
+ ): Effect.Effect<BaoMfaTotpMethodAttributes | undefined, BaoError, HttpClient.HttpClient> =>
59
+ Effect.map(findTotp(props.name), (found) =>
60
+ found === undefined ? undefined : attributesOf(found.id, found.live),
61
+ );
62
+
63
+ export const planTotp = (
64
+ props: BaoMfaTotpMethodProps,
65
+ ): Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient> =>
66
+ Effect.gen(function* () {
67
+ if (problems(props).length > 0) return 'update';
68
+ const live = yield* readTotp(props);
69
+ return live !== undefined && matches(live, props) ? 'noop' : 'update';
70
+ });
71
+
72
+ /** Upsert by name when live differs, then prove it by reading back. */
73
+ export const reconcileTotp = (
74
+ props: BaoMfaTotpMethodProps,
75
+ ): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
76
+ Effect.gen(function* () {
77
+ const bad = problems(props);
78
+ if (bad.length > 0) return yield* refuse(props, bad.join('; '));
79
+ const live = yield* readTotp(props);
80
+ if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
81
+ const after = yield* readTotp(props);
82
+ if (after === undefined)
83
+ return yield* refuse(props, 'the write succeeded but no method has that name.');
84
+ if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
85
+ return after;
86
+ });
87
+
88
+ export const BaoMfaTotpMethodProvider = () =>
89
+ Provider.effect(
90
+ BaoMfaTotpMethod,
91
+ Effect.succeed(
92
+ BaoMfaTotpMethod.Provider.of({
93
+ /** ⛔ The namespace's method listing is not a list of things this owns. */
94
+ list: () => Effect.succeed([]),
95
+
96
+ read: Effect.fn(function* ({ olds }) {
97
+ return yield* readTotp(olds);
98
+ }),
99
+
100
+ diff: Effect.fn(function* ({ news, output }) {
101
+ if (output === undefined || !isResolved(news)) return undefined;
102
+ const rename = renameProblem(output.name, news.name);
103
+ if (rename !== undefined) return yield* refuse(news, rename);
104
+ return { action: yield* planTotp(news) } as const;
105
+ }),
106
+
107
+ reconcile: Effect.fn(function* ({ news }) {
108
+ return yield* reconcileTotp(news);
109
+ }),
110
+
111
+ /** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
112
+ delete: Effect.fn(function* ({ output }) {
113
+ yield* deleteTotp(output.methodId);
114
+ return undefined;
115
+ }),
116
+ }),
117
+ ),
118
+ );
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The OpenBao calls behind login MFA: TOTP methods under identity/mfa/method/totp, login
3
+ * enforcements under identity/mfa/login-enforcement/<name>, and the auth table that turns a mount
4
+ * path into the accessor an enforcement names.
5
+ *
6
+ * ★ OpenBao 2.6.2 SHIPS LOGIN MFA — confirmed in source before any of this was written:
7
+ * vault/identity/store.go:312-800 (mfaPaths) registers `mfa/method/totp`, `mfa/login-enforcement/
8
+ * <name>` and their listings on the identity backend; vault/identity/mfa.go holds the handlers
9
+ * and vault/login_mfa.go the storage.
10
+ * ★ A TOTP METHOD IS FOUND BY NAME, NOT ID. The id is a server-made UUID; `method_name` is the only
11
+ * thing a declaration can know. An update without `method_id` UPSERTS by name within the request's
12
+ * namespace (identity/mfa.go:159-230, MemDBMFAConfigByName keys on namespace + name), so the same
13
+ * write both creates and updates. The listing (`key_info`, one entry per id, :63-69) answers the
14
+ * methods of this namespace AND its parents (login_mfa.go:652-713), so the match also requires
15
+ * this namespace's `namespace_path`.
16
+ * ⛔ THE NAME INDEX IGNORES THE TYPE, SO THIS LISTS EVERY TYPE. login_mfa.go's `name` index is
17
+ * (namespace_id, name) across all methods (:1642-1655, :1818), and handleMFAMethodUpdateCommon
18
+ * then sets `mConfig.Type = "totp"` on whatever it found (identity/mfa.go:228). A TOTP write under
19
+ * the name of a Duo or Okta method would silently CONVERT that method — and every enforcement
20
+ * naming its id would start demanding a TOTP code nobody is enrolled for. The per-type listing
21
+ * (`.../method/totp?list=true`) cannot see the clash, so this reads the global one
22
+ * (`identity/mfa/method?list=true`, store.go:335) and refuses a same-named method of
23
+ * another type. ⚠️ The deploying token needs `list` on `identity/mfa/method`.
24
+ * ⚠️ AN UPDATE BY A STALE `method_id` IS A SILENT NO-OP: the handler returns nil for an unknown id
25
+ * (identity/mfa.go:172-183), which HTTP answers 204. So this never writes by id.
26
+ */
27
+ import * as Effect from 'effect/Effect';
28
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
29
+ import { BaoEnv, baoCall, baoDelete, baoRead } from './bao-http.ts';
30
+ import { envNamespace, namespacePath } from './bao-namespace.ts';
31
+ import type { BaoError } from './bao-status.ts';
32
+ import { mountPath } from './mount-form.ts';
33
+
34
+ export const TOTP_PATH = 'identity/mfa/method/totp';
35
+ /** Every MFA method of every type — the only listing that can see a name clash across types. */
36
+ export const METHODS_PATH = 'identity/mfa/method';
37
+ export const enforcementPath = (name: string): string => `identity/mfa/login-enforcement/${name}`;
38
+
39
+ const record = (value: unknown): Record<string, unknown> | undefined =>
40
+ typeof value === 'object' && value !== null && !Array.isArray(value)
41
+ ? (value as Record<string, unknown>)
42
+ : undefined;
43
+
44
+ /** This call's namespace as OpenBao writes `namespace_path`: `''` or `team-a/`. */
45
+ export const ownNamespacePath: Effect.Effect<string> = Effect.gen(function* () {
46
+ const env = yield* BaoEnv;
47
+ return namespacePath(envNamespace(env));
48
+ });
49
+
50
+ export type TotpEntry = { readonly id: string; readonly live: Record<string, unknown> };
51
+
52
+ /**
53
+ * The TOTP method named `name` in this namespace, or undefined. Dies when the name belongs to a
54
+ * method of ANOTHER type here — writing would convert it (the ⛔ above).
55
+ */
56
+ export const findTotp = (
57
+ name: string,
58
+ ): Effect.Effect<TotpEntry | undefined, BaoError, HttpClient.HttpClient> =>
59
+ Effect.gen(function* () {
60
+ const listing = yield* baoRead(`${METHODS_PATH}?list=true`);
61
+ const info = record(listing?.['key_info']) ?? {};
62
+ const own = yield* ownNamespacePath;
63
+ for (const [id, raw] of Object.entries(info)) {
64
+ const live = record(raw);
65
+ if (live === undefined || live['name'] !== name || live['namespace_path'] !== own) continue;
66
+ if (live['type'] !== 'totp') {
67
+ return yield* Effect.die(
68
+ new Error(
69
+ `Bao.MfaTotpMethod ${name}: that name belongs to a ${String(live['type'])} method ` +
70
+ `(${id}) in this namespace, and a TOTP write would convert it. Pick another name.`,
71
+ ),
72
+ );
73
+ }
74
+ return { id, live };
75
+ }
76
+ return undefined;
77
+ });
78
+
79
+ /** Create-or-update by `method_name` — never by id (the ⚠️ above). */
80
+ export const writeTotp = (body: Readonly<Record<string, unknown>>) =>
81
+ Effect.asVoid(baoCall('write', 'POST', TOTP_PATH, body));
82
+
83
+ /**
84
+ * ⛔ DESTROYS EVERY ENTITY'S ENROLLED SECRET FOR THIS METHOD (login_mfa.go:1965-1973 clears the TOTP
85
+ * keys under the method id). OpenBao refuses it while an enforcement in memory still names the id
86
+ * (:1914-1924) — a refusal this passes on.
87
+ */
88
+ export const deleteTotp = (id: string) => baoDelete(`${TOTP_PATH}/${id}`);
89
+
90
+ export const readEnforcement = (name: string) => baoRead(enforcementPath(name));
91
+
92
+ export const writeEnforcement = (name: string, body: Readonly<Record<string, unknown>>) =>
93
+ Effect.asVoid(baoCall('write', 'POST', enforcementPath(name), body));
94
+
95
+ /**
96
+ * Accessors for auth mount paths, from `GET sys/auth` (keys are `<path>/`). A path with no mount
97
+ * comes back in `missing`, never as a guess.
98
+ */
99
+ export const authAccessors = (
100
+ paths: readonly string[],
101
+ ): Effect.Effect<
102
+ { readonly accessors: readonly string[]; readonly missing: readonly string[] },
103
+ BaoError,
104
+ HttpClient.HttpClient
105
+ > =>
106
+ Effect.gen(function* () {
107
+ if (paths.length === 0) return { accessors: [], missing: [] };
108
+ const table = (yield* baoRead('sys/auth')) ?? {};
109
+ const accessors: string[] = [];
110
+ const missing: string[] = [];
111
+ for (const path of paths) {
112
+ const accessor = record(table[`${mountPath(path)}/`])?.['accessor'];
113
+ if (typeof accessor === 'string' && accessor !== '') accessors.push(accessor);
114
+ else missing.push(mountPath(path));
115
+ }
116
+ return { accessors, missing };
117
+ });