@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,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
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Login MFA against a fake: a TOTP method found by name in THIS namespace only, and an enforcement
3
+ * whose mount paths resolve to accessors and whose lists always go over as arrays. Ids and names are
4
+ * placeholders.
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { describe, it } from 'node:test';
8
+ import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
9
+ import { planEnforcement, reconcileEnforcement } from './mfa-enforcement.ts';
10
+ import { planTotp, reconcileTotp } from './mfa-totp.ts';
11
+ import { renameProblem } from './mfa-totp-form.ts';
12
+
13
+ const ENV = (address: string) => ({ BAO_ADDR: address, BAO_NAMESPACE: 'team-a' });
14
+ const TOTP = { issuer: 'vault-example', name: 'admin-totp' };
15
+ const METHOD_ID = '00000000-0000-4000-8000-00000000000a';
16
+ const stored = (namespacePath: string, extra: Record<string, unknown> = {}) => ({
17
+ algorithm: 'SHA1',
18
+ digits: 6,
19
+ id: METHOD_ID,
20
+ issuer: 'vault-example',
21
+ key_size: 20,
22
+ max_validation_attempts: 5,
23
+ name: 'admin-totp',
24
+ namespace_path: namespacePath,
25
+ period: 30,
26
+ qr_size: 200,
27
+ skew: 1,
28
+ type: 'totp',
29
+ ...extra,
30
+ });
31
+ const listing = (...entries: Record<string, unknown>[]): Reply =>
32
+ entries.length === 0
33
+ ? { json: { errors: [] }, status: 404 }
34
+ : {
35
+ json: {
36
+ data: {
37
+ key_info: Object.fromEntries(entries.map((e, i) => [`id-${String(i)}`, e])),
38
+ keys: [],
39
+ },
40
+ },
41
+ status: 200,
42
+ };
43
+
44
+ /** Listing replies in order (the last repeats); every write answers 200. */
45
+ const totpFake = (...lists: Reply[]) => {
46
+ let reads = 0;
47
+ return (seen: Seen): Reply => {
48
+ if (seen.method !== 'GET') return { json: { data: { method_id: METHOD_ID } }, status: 200 };
49
+ const reply = lists[Math.min(reads, lists.length - 1)] ?? listing();
50
+ reads += 1;
51
+ return reply;
52
+ };
53
+ };
54
+ const writes = (seen: Seen[]) => seen.filter((each) => each.method !== 'GET');
55
+
56
+ describe('Bao.MfaTotpMethod', () => {
57
+ it('upserts by method_name when absent, and returns the id it read back', async () => {
58
+ await withFake(totpFake(listing(), listing(stored('team-a/'))), async (bao) => {
59
+ const attributes = await run(ENV(bao.address), reconcileTotp(TOTP));
60
+ assert.equal(attributes.methodId, 'id-0');
61
+ const [write] = writes(bao.seen);
62
+ assert.equal(write?.path, '/v1/identity/mfa/method/totp');
63
+ const body = JSON.parse(write?.body ?? '{}') as Record<string, unknown>;
64
+ assert.equal(body['method_name'], 'admin-totp');
65
+ assert.equal(body['method_id'], undefined);
66
+ assert.equal(body['period'], '30s');
67
+ });
68
+ });
69
+
70
+ it('plans noop and writes nothing when this namespace already has it', async () => {
71
+ await withFake(totpFake(listing(stored('team-a/'))), async (bao) => {
72
+ assert.equal(await run(ENV(bao.address), planTotp(TOTP)), 'noop');
73
+ await run(ENV(bao.address), reconcileTotp(TOTP));
74
+ assert.equal(writes(bao.seen).length, 0);
75
+ });
76
+ });
77
+
78
+ it("ignores a same-named method in a parent namespace — it is not this namespace's", async () => {
79
+ await withFake(
80
+ totpFake(listing(stored('')), listing(stored(''), stored('team-a/'))),
81
+ async (bao) => {
82
+ await run(ENV(bao.address), reconcileTotp(TOTP));
83
+ assert.equal(writes(bao.seen).length, 1);
84
+ },
85
+ );
86
+ });
87
+
88
+ it('refuses a rename at plan time — a new id would strand every enrolled secret', () => {
89
+ assert.equal(renameProblem('admin-totp', 'admin-totp'), undefined);
90
+ assert.match(renameProblem('admin-totp', 'admin-otp') ?? '', /strand every enrolled secret/);
91
+ });
92
+
93
+ it('refuses a name another method type holds here — a TOTP write would convert it', async () => {
94
+ await withFake(totpFake(listing(stored('team-a/', { type: 'duo' }))), async (bao) => {
95
+ await assert.rejects(run(ENV(bao.address), reconcileTotp(TOTP)), /belongs to a duo method/);
96
+ assert.equal(writes(bao.seen).length, 0);
97
+ // ★ The GLOBAL listing — the per-type one cannot see a Duo method at all.
98
+ assert.equal(bao.seen[0]?.path, '/v1/identity/mfa/method?list=true');
99
+ });
100
+ });
101
+
102
+ it('plans update on drift, and refuses a bad declaration before any call', async () => {
103
+ await withFake(totpFake(listing(stored('team-a/', { digits: 8 }))), async (bao) => {
104
+ assert.equal(await run(ENV(bao.address), planTotp(TOTP)), 'update');
105
+ });
106
+ await withFake(totpFake(listing()), async (bao) => {
107
+ await assert.rejects(
108
+ run(ENV(bao.address), reconcileTotp({ ...TOTP, issuer: '' })),
109
+ /issuer is empty/,
110
+ );
111
+ assert.equal(bao.seen.length, 0);
112
+ });
113
+ });
114
+ });
115
+
116
+ const AUTH_TABLE: Reply = {
117
+ json: {
118
+ data: {
119
+ 'oidc/': { accessor: 'auth_oidc_1', type: 'oidc' },
120
+ 'token/': { accessor: 'auth_token_1', type: 'token' },
121
+ },
122
+ },
123
+ status: 200,
124
+ };
125
+ const ENFORCE = { authMethodPaths: ['oidc'], mfaMethodIds: [METHOD_ID], name: 'admin-mfa' };
126
+ const enforcementLive = (accessors: string[]) => ({
127
+ json: {
128
+ data: {
129
+ auth_method_accessors: accessors,
130
+ auth_method_types: [],
131
+ id: 'enf-1',
132
+ identity_entity_ids: [],
133
+ identity_group_ids: [],
134
+ mfa_method_ids: [METHOD_ID],
135
+ name: 'admin-mfa',
136
+ namespace_path: 'team-a/',
137
+ },
138
+ },
139
+ status: 200,
140
+ });
141
+
142
+ /** sys/auth answers the table; the enforcement answers `states` in order; writes answer 204. */
143
+ const enforcementFake = (...states: Reply[]) => {
144
+ let reads = 0;
145
+ return (seen: Seen): Reply => {
146
+ if (seen.path === '/v1/sys/auth') return AUTH_TABLE;
147
+ if (seen.method !== 'GET') return { status: 204 };
148
+ const reply = states[Math.min(reads, states.length - 1)] ?? {
149
+ json: { errors: [] },
150
+ status: 404,
151
+ };
152
+ reads += 1;
153
+ return reply;
154
+ };
155
+ };
156
+
157
+ describe('Bao.MfaLoginEnforcement', () => {
158
+ it('resolves mount paths to accessors and sends every list as an array', async () => {
159
+ const absent = { json: { errors: [] }, status: 404 };
160
+ await withFake(enforcementFake(absent, enforcementLive(['auth_oidc_1'])), async (bao) => {
161
+ const attributes = await run(ENV(bao.address), reconcileEnforcement(ENFORCE));
162
+ assert.equal(attributes.enforcementId, 'enf-1');
163
+ const [write] = writes(bao.seen);
164
+ assert.equal(write?.path, '/v1/identity/mfa/login-enforcement/admin-mfa');
165
+ assert.deepEqual(JSON.parse(write?.body ?? '{}'), {
166
+ auth_method_accessors: ['auth_oidc_1'],
167
+ auth_method_types: [],
168
+ identity_entity_ids: [],
169
+ identity_group_ids: [],
170
+ mfa_method_ids: [METHOD_ID],
171
+ });
172
+ });
173
+ });
174
+
175
+ it('plans update when live carries a target the declaration does not', async () => {
176
+ await withFake(
177
+ enforcementFake(enforcementLive(['auth_oidc_1', 'auth_token_1'])),
178
+ async (bao) => {
179
+ assert.equal(await run(ENV(bao.address), planEnforcement(ENFORCE)), 'update');
180
+ },
181
+ );
182
+ await withFake(enforcementFake(enforcementLive(['auth_oidc_1'])), async (bao) => {
183
+ assert.equal(await run(ENV(bao.address), planEnforcement(ENFORCE)), 'noop');
184
+ });
185
+ });
186
+
187
+ it('refuses a path with no mount, and an enforcement with no target, before writing', async () => {
188
+ await withFake(enforcementFake(), async (bao) => {
189
+ await assert.rejects(
190
+ run(ENV(bao.address), reconcileEnforcement({ ...ENFORCE, authMethodPaths: ['jwt-admin'] })),
191
+ /no auth mount at jwt-admin/,
192
+ );
193
+ await assert.rejects(
194
+ run(ENV(bao.address), reconcileEnforcement({ ...ENFORCE, authMethodPaths: [] })),
195
+ /enforces NOTHING/,
196
+ );
197
+ assert.equal(writes(bao.seen).length, 0);
198
+ });
199
+ });
200
+ });
@@ -1,7 +1,8 @@
1
1
  import { sha256 } from './digest.ts';
2
+ import { trimTrailingSlashes } from './mount-path.ts';
2
3
 
3
4
  /** Mount path without a trailing slash — props and `sys/mounts/<path>` use this shape. */
4
- export const mountPath = (path: string) => path.replace(/\/+$/, '');
5
+ export const mountPath = (path: string) => trimTrailingSlashes(path);
5
6
 
6
7
  const DURATION = /^(\d+)([smhd])$/;
7
8
 
@@ -46,6 +47,13 @@ export interface BaoMountProps {
46
47
  maxLeaseTtl?: string;
47
48
  /** KV only — sent as `options.version` on enable, as `-version=2` was. Immutable after create. */
48
49
  version?: 1 | 2;
50
+ /**
51
+ * The path this mount lives at NOW, when `path` is where it should move to. Moves it with
52
+ * `sys/remount`, keeping every secret. Without it, a changed `path` fails the plan — see
53
+ * mount-move.ts. ⛔ Every lease under the old path is revoked, and policies are not rewritten.
54
+ * Harmless to leave declared after the move: once state holds `path`, it is ignored.
55
+ */
56
+ remountFrom?: string;
49
57
  }
50
58
 
51
59
  export interface BaoMountAttributes {