@homeflare/alchemy 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +17 -48
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -44,12 +44,13 @@ import * as Provider from 'alchemy/Provider';
44
44
  import * as Effect from 'effect/Effect';
45
45
  import type * as HttpClient from 'effect/unstable/http/HttpClient';
46
46
  import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
47
- import { mountPath, parseDuration } from './mount-form.ts';
47
+ import { mountPath } from './mount-form.ts';
48
48
  import {
49
49
  type BaoPkiRoleAttributes,
50
50
  type BaoPkiRoleProps,
51
51
  attributesOf,
52
52
  matches,
53
+ problems,
53
54
  rolePath,
54
55
  writeBody,
55
56
  } from './pki-role-form.ts';
@@ -75,12 +76,6 @@ const readRole = (props: BaoPkiRoleProps) =>
75
76
  return attributesOf(props, live);
76
77
  });
77
78
 
78
- /** Duration props `bao write` must never see — reconcile turns these into a refusal. */
79
- const badDurations = (props: BaoPkiRoleProps) =>
80
- [['ttl', props.ttl] as const, ['maxTtl', props.maxTtl] as const].filter(
81
- ([, text]) => parseDuration(text) === undefined,
82
- );
83
-
84
79
  export const BaoPkiRoleProvider = () =>
85
80
  Provider.effect(
86
81
  BaoPkiRole,
@@ -111,18 +106,20 @@ export const BaoPkiRoleProvider = () =>
111
106
  * ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
112
107
  * engine is a different object under a different CA, never an in-place edit —
113
108
  * `replace`, so Alchemy creates the new one and retires the old.
109
+ * ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
110
+ * so a new `name` read nothing at the new path, planned `update`, wrote the new role,
111
+ * and left the old one issuing certificates under no state record at all.
114
112
  */
115
- if (mountPath(news.mount ?? 'pki') !== output.mount)
113
+ if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
116
114
  return { action: 'replace' } as const;
117
115
  /**
118
116
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
119
117
  * allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
120
118
  * live role already in that state — and a noop there hides the refusal behind a
121
119
  * clean plan until the next deploy. Route it to reconcile, which says why.
120
+ * (The full list is `problems` in pki-role-form.ts.)
122
121
  */
123
- if (news.allowedDomains.length === 0 || badDurations(news).length > 0) {
124
- return { action: 'update' } as const;
125
- }
122
+ if (problems(news).length > 0) return { action: 'update' } as const;
126
123
  const live = yield* readRole(news);
127
124
  if (live === undefined) return { action: 'update' } as const;
128
125
  return matches(live, news)
@@ -136,24 +133,12 @@ export const BaoPkiRoleProvider = () =>
136
133
  * ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
137
134
  * false on every live role, so a role with no allowed domains can issue NOTHING —
138
135
  * and an unresolved or mistyped prop reads exactly like an author who meant it.
139
- * policy.ts refuses an empty fragment directory for the same reason.
136
+ * policy.ts refuses an empty fragment directory for the same reason. The other
137
+ * refusals — an unparseable duration, noStore with generateLease — are in `problems`.
140
138
  */
141
- if (news.allowedDomains.length === 0) {
142
- return yield* Effect.die(
143
- new Error(
144
- `Bao.PkiRole ${path}: allowedDomains is empty. With allow_any_name false ` +
145
- 'that role can issue no certificate at all.',
146
- ),
147
- );
148
- }
149
- const bad = badDurations(news);
139
+ const bad = problems(news);
150
140
  if (bad.length > 0) {
151
- return yield* Effect.die(
152
- new Error(
153
- `Bao.PkiRole ${path}: ${bad.map(([k, v]) => `${k}=${v}`).join(', ')} ` +
154
- 'is not a duration OpenBao parses (expect 30m, 720h, 8760h or 0).',
155
- ),
156
- );
141
+ return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
157
142
  }
158
143
  const live = yield* readRole(news);
159
144
  if (live === undefined || !matches(live, news)) {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * sys/remount and its status loop against a fake. The shapes pinned here are the CLI's own
3
+ * (api/sys_mounts.go:95-191): POST `{from, to}`, then poll until `success` or `failure`.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
9
+ import { RemountError, remountAndWait } from './remount-wire.ts';
10
+
11
+ const STARTED = { json: { data: { migration_id: 'm-1' }, warnings: ['queued'] }, status: 200 };
12
+ const status = (value: string): Reply => ({
13
+ json: {
14
+ data: {
15
+ migration_id: 'm-1',
16
+ migration_info: { source_mount: 'kv/', status: value, target_mount: 'kv-moved/' },
17
+ },
18
+ },
19
+ status: 200,
20
+ });
21
+ const FORGOTTEN: Reply = { json: { errors: [] }, status: 404 };
22
+
23
+ /** Answer the start, then each status read from `statuses` in order (the last one repeats). */
24
+ const script = (...statuses: Reply[]) => {
25
+ let polls = 0;
26
+ return (seen: Seen): Reply => {
27
+ if (seen.path === '/v1/sys/remount') return STARTED;
28
+ const reply = statuses[Math.min(polls, statuses.length - 1)] ?? FORGOTTEN;
29
+ polls += 1;
30
+ return reply;
31
+ };
32
+ };
33
+
34
+ const FAST = { attempts: 3, intervalMillis: 1 };
35
+ const moved = (confirm = Effect.succeed(true)) => remountAndWait('kv', 'kv-moved', confirm, FAST);
36
+
37
+ describe('remountAndWait', () => {
38
+ it('posts from/to and polls until success', async () => {
39
+ await withFake(script(status('in-progress'), status('success')), async (bao) => {
40
+ await run({ BAO_ADDR: bao.address }, moved());
41
+ assert.deepEqual(
42
+ bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
43
+ [
44
+ ['POST', '/v1/sys/remount', JSON.stringify({ from: 'kv', to: 'kv-moved' })],
45
+ ['GET', '/v1/sys/remount/status/m-1', ''],
46
+ ['GET', '/v1/sys/remount/status/m-1', ''],
47
+ ],
48
+ );
49
+ });
50
+ });
51
+
52
+ it('fails a migration OpenBao reports failed, naming the id', async () => {
53
+ await withFake(script(status('failure')), async (bao) => {
54
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), (error: unknown) => {
55
+ assert.ok(error instanceof RemountError);
56
+ assert.match(error.message, /migration m-1\) FAILED/);
57
+ return true;
58
+ });
59
+ });
60
+ });
61
+
62
+ it('settles a forgotten migration from the mount tables, both ways', async () => {
63
+ await withFake(script(FORGOTTEN), async (bao) => {
64
+ await run({ BAO_ADDR: bao.address }, moved(Effect.succeed(true)));
65
+ });
66
+ await withFake(script(FORGOTTEN), async (bao) => {
67
+ await assert.rejects(
68
+ run({ BAO_ADDR: bao.address }, moved(Effect.succeed(false))),
69
+ /no longer knows the migration/,
70
+ );
71
+ });
72
+ });
73
+
74
+ it('gives up after the bounded number of status reads', async () => {
75
+ await withFake(script(status('in-progress')), async (bao) => {
76
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /still in progress after 3/);
77
+ assert.equal(bao.seen.length, 4);
78
+ });
79
+ });
80
+
81
+ it('fails a start that names no migration id', async () => {
82
+ const answer = () => ({ json: { data: {} }, status: 200 });
83
+ await withFake(answer, async (bao) => {
84
+ await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /no migration_id/);
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `sys/remount` — move a secrets engine or auth method to a new path WITH its data — and the status
3
+ * polling it needs, because the move finishes after the call returns.
4
+ *
5
+ * ★ READ FROM openbao v2.6.2, NOT RECALLED:
6
+ * · api/sys_mounts.go:95-191 — the CLI's `bao secrets move` POSTs `{from, to}` to sys/remount,
7
+ * takes `data.migration_id`, then GETs sys/remount/status/<id> once a second until
8
+ * `migration_info.status` is `success` or `failure`. This is that loop, bounded.
9
+ * · vault/logical_system.go:1257-1357 — the handler validates, records the migration and
10
+ * answers AT ONCE; a goroutine does the move. So a 200 means "queued", never "moved".
11
+ * · vault/mount.go:630-735 — the mount keeps its UUID, and storage lives under the UUID, so every
12
+ * secret stays. ⛔ BUT EVERY LEASE UNDER THE SOURCE IS REVOKED (`RevokePrefix`, :684): a move of
13
+ * a dynamic-secrets mount kills every credential it has issued. KV has no leases.
14
+ * · vault/logical_system.go:67-69 — `remount` is a sudo path. The status path is not.
15
+ * ⚠️ THE STATUS TRACKER IS IN MEMORY (vault/mount.go:2303-2336, a sync.Map on the active node). A
16
+ * restart or leader change mid-move loses it, and the status read then answers 404. That is not a
17
+ * failure and not a success; `confirm` settles it from the mount tables, or the call fails.
18
+ * ⛔ POLICIES ARE NOT REWRITTEN. Every ACL path naming the old mount keeps naming it.
19
+ */
20
+ import * as Effect from 'effect/Effect';
21
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import { baoCall, baoRead } from './bao-http.ts';
23
+ import type { BaoError } from './bao-status.ts';
24
+
25
+ /** A move OpenBao reported failed, or one this could not confirm. Never carries a token. */
26
+ export class RemountError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'RemountError';
30
+ }
31
+ }
32
+
33
+ export interface RemountOptions {
34
+ /** Milliseconds between status reads. Default 1000 — the CLI's own pace. */
35
+ readonly intervalMillis?: number;
36
+ /** Status reads before giving up. Default 120, so about two minutes. */
37
+ readonly attempts?: number;
38
+ }
39
+
40
+ const record = (value: unknown): Record<string, unknown> | undefined =>
41
+ typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ ? (value as Record<string, unknown>)
43
+ : undefined;
44
+
45
+ /** POST sys/remount and return the migration id. `from`/`to` are full paths (`auth/x` for auth). */
46
+ export const startRemount = (
47
+ from: string,
48
+ to: string,
49
+ ): Effect.Effect<string, BaoError | RemountError, HttpClient.HttpClient> =>
50
+ Effect.flatMap(baoCall('write', 'POST', 'sys/remount', { from, to }), (body) => {
51
+ const id = record(body?.['data'])?.['migration_id'];
52
+ return typeof id === 'string' && id !== ''
53
+ ? Effect.succeed(id)
54
+ : Effect.fail(new RemountError(`sys/remount ${from} -> ${to} answered no migration_id.`));
55
+ });
56
+
57
+ /** `in-progress`, `success`, `failure` — or undefined when the server no longer knows the id. */
58
+ export const remountStatus = (
59
+ id: string,
60
+ ): Effect.Effect<string | undefined, BaoError, HttpClient.HttpClient> =>
61
+ Effect.map(baoRead(`sys/remount/status/${encodeURIComponent(id)}`), (data) => {
62
+ if (data === undefined) return undefined;
63
+ const status = record(data['migration_info'])?.['status'];
64
+ return typeof status === 'string' ? status : 'unknown';
65
+ });
66
+
67
+ /**
68
+ * Move `from` to `to` and wait until OpenBao says it is done.
69
+ *
70
+ * @param confirm answers "does the mount table show the move finished?" — asked only when the
71
+ * status tracker has forgotten the id. True is success; false fails.
72
+ */
73
+ export const remountAndWait = (
74
+ from: string,
75
+ to: string,
76
+ confirm: Effect.Effect<boolean, BaoError, HttpClient.HttpClient>,
77
+ options: RemountOptions = {},
78
+ ): Effect.Effect<void, BaoError | RemountError, HttpClient.HttpClient> =>
79
+ Effect.gen(function* () {
80
+ const interval = options.intervalMillis ?? 1000;
81
+ const attempts = Math.max(1, options.attempts ?? 120);
82
+ const id = yield* startRemount(from, to);
83
+ const named = `sys/remount ${from} -> ${to} (migration ${id})`;
84
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
85
+ const status = yield* remountStatus(id);
86
+ if (status === 'success') return;
87
+ if (status === 'failure') {
88
+ return yield* Effect.fail(
89
+ new RemountError(
90
+ `${named} FAILED. The server log carries the reason, tagged with that migration_id. ` +
91
+ 'Read sys/mounts (or sys/auth) before retrying: the source may be tainted.',
92
+ ),
93
+ );
94
+ }
95
+ if (status === undefined) {
96
+ if (yield* confirm) return;
97
+ return yield* Effect.fail(
98
+ new RemountError(
99
+ `${named}: the server no longer knows the migration (its tracker is in memory, lost ` +
100
+ 'on a restart or leader change), and the mount table does not show the move done.',
101
+ ),
102
+ );
103
+ }
104
+ if (attempt < attempts) yield* Effect.sleep(interval);
105
+ }
106
+ return yield* Effect.fail(
107
+ new RemountError(
108
+ `${named} is still in progress after ${String(attempts)} status reads. Do not re-run ` +
109
+ `until sys/remount/status/${id} settles.`,
110
+ ),
111
+ );
112
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The shared role loop against a fake, through Bao.JwtAuthConfig — the family where `wouldErase`
3
+ * matters most: a live OIDC client must never be written over, because the write would erase a
4
+ * secret the read never shows.
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 { jwtConfigSpec } from './jwt-config.ts';
10
+ import { problems } from './jwt-config-form.ts';
11
+ import { planRole, reconcileRole } from './role-reconcile.ts';
12
+
13
+ const PROPS = {
14
+ boundIssuer: 'https://token.actions.example.com',
15
+ mount: 'jwt-github',
16
+ oidcDiscoveryUrl: 'https://token.actions.example.com',
17
+ };
18
+
19
+ const STORED = {
20
+ bound_issuer: 'https://token.actions.example.com',
21
+ default_role: '',
22
+ jwks_ca_pem: '',
23
+ jwks_url: '',
24
+ jwt_supported_algs: [],
25
+ jwt_validation_pubkeys: [],
26
+ namespace_in_state: true,
27
+ oidc_client_id: '',
28
+ oidc_discovery_ca_pem: '',
29
+ oidc_discovery_url: 'https://token.actions.example.com',
30
+ oidc_response_mode: '',
31
+ oidc_response_types: [],
32
+ override_allowed_server_names: null,
33
+ provider_config: {},
34
+ status: 'valid',
35
+ };
36
+
37
+ /** Reads answer from `reads` in order (the last repeats); writes answer 204. */
38
+ const script = (...reads: Reply[]) => {
39
+ let count = 0;
40
+ return (seen: Seen): Reply => {
41
+ if (seen.method !== 'GET') return { status: 204 };
42
+ const reply = reads[Math.min(count, reads.length - 1)] ?? { status: 500 };
43
+ count += 1;
44
+ return reply;
45
+ };
46
+ };
47
+ const ABSENT: Reply = { json: { errors: [] }, status: 404 };
48
+ const found = (data: Record<string, unknown>): Reply => ({ json: { data }, status: 200 });
49
+ const writes = (seen: Seen[]) => seen.filter((each) => each.method !== 'GET');
50
+
51
+ describe('role loop (Bao.JwtAuthConfig)', () => {
52
+ it('writes an absent config once, as PUT, and returns what it read back', async () => {
53
+ await withFake(script(ABSENT, found(STORED)), async (bao) => {
54
+ const attributes = await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
55
+ assert.equal(attributes.mount, 'jwt-github');
56
+ const [write] = writes(bao.seen);
57
+ assert.equal(write?.method, 'PUT');
58
+ assert.equal(write?.path, '/v1/auth/jwt-github/config');
59
+ assert.ok(!(write?.body ?? '').includes('oidc_client_secret'));
60
+ });
61
+ });
62
+
63
+ it('writes nothing and plans noop when live already matches', async () => {
64
+ await withFake(script(found(STORED)), async (bao) => {
65
+ assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(PROPS))), 'noop');
66
+ await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
67
+ assert.equal(writes(bao.seen).length, 0);
68
+ });
69
+ });
70
+
71
+ it('refuses to write over a live OIDC client, naming what would be erased', async () => {
72
+ const oidc = found({
73
+ ...STORED,
74
+ bound_issuer: 'https://old.example.com',
75
+ oidc_client_id: 'client-a',
76
+ });
77
+ await withFake(script(oidc), async (bao) => {
78
+ await assert.rejects(
79
+ run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
80
+ /oidc_client_id \(and its unreadable secret\)/,
81
+ );
82
+ assert.equal(writes(bao.seen).length, 0);
83
+ });
84
+ });
85
+
86
+ it('refuses a write that reads back different', async () => {
87
+ const other = found({ ...STORED, bound_issuer: 'https://elsewhere.example.com' });
88
+ await withFake(script(ABSENT, other), async (bao) => {
89
+ await assert.rejects(
90
+ run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
91
+ /read back different/,
92
+ );
93
+ });
94
+ });
95
+
96
+ it('plans update, never noop, for a declaration it would refuse', async () => {
97
+ const bad = { ...PROPS, jwksUrl: 'https://keys.example.com' };
98
+ assert.match(problems(bad).join(), /exactly one/);
99
+ await withFake(script(found(STORED)), async (bao) => {
100
+ assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(bad))), 'update');
101
+ assert.equal(bao.seen.length, 0);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The read / plan / reconcile loop the newer role families share (Bao.JwtRole, Bao.KubernetesRole,
3
+ * Bao.JwtAuthConfig): one path, one body, one comparison. Each family supplies its own form; this
4
+ * owns the order of operations, which is where the estate's providers have gone wrong before.
5
+ *
6
+ * ★ THE ORDER, AND WHY EACH STEP IS THERE:
7
+ * 1. Refuse a declaration the family knows is wrong, before any call (`problems`).
8
+ * 2. Read live. Write only when it differs — an identical write is still an audit line.
9
+ * 3. Before writing over a live object, refuse if the write would silently destroy something
10
+ * this resource does not model (`wouldErase`, the ssh-role.ts pattern).
11
+ * 4. Re-read, and refuse unless live now MATCHES. A 2xx is not proof: the server can accept a
12
+ * field and store something else (pki-role.ts has the history).
13
+ * ⛔ A DECLARATION reconcile WOULD REFUSE NEVER PLANS `noop` — `planRole` routes it to `update`,
14
+ * so the refusal is seen at the next deploy instead of hiding behind a clean plan.
15
+ */
16
+ import * as Effect from 'effect/Effect';
17
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
18
+ import { baoRead, baoWrite } from './bao-http.ts';
19
+ import type { BaoError } from './bao-status.ts';
20
+
21
+ export interface RoleSpec<A> {
22
+ /** `Bao.JwtRole` etc., for messages. */
23
+ readonly family: string;
24
+ /** The API path, e.g. `auth/jwt/role/humans`. */
25
+ readonly path: string;
26
+ readonly problems: readonly string[];
27
+ readonly attributesOf: (live: Record<string, unknown>) => A;
28
+ readonly matches: (attributes: A) => boolean;
29
+ readonly body: Readonly<Record<string, unknown>>;
30
+ /** Live fields a write would reset that the declaration does not model. */
31
+ readonly wouldErase?: (live: Record<string, unknown>) => readonly string[];
32
+ }
33
+
34
+ type Found<A> = { readonly attributes: A; readonly live: Record<string, unknown> };
35
+
36
+ export const readRoleAt = <A>(
37
+ spec: RoleSpec<A>,
38
+ ): Effect.Effect<Found<A> | undefined, BaoError, HttpClient.HttpClient> =>
39
+ Effect.map(baoRead(spec.path), (live) =>
40
+ live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
41
+ );
42
+
43
+ /** `update` or `noop` — the part of a diff after the identity checks. */
44
+ export const planRole = <A>(
45
+ spec: RoleSpec<A>,
46
+ ): Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient> =>
47
+ Effect.gen(function* () {
48
+ if (spec.problems.length > 0) return 'update';
49
+ const found = yield* readRoleAt(spec);
50
+ return found !== undefined && spec.matches(found.attributes) ? 'noop' : 'update';
51
+ });
52
+
53
+ const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
54
+ Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
55
+
56
+ export const reconcileRole = <A>(
57
+ spec: RoleSpec<A>,
58
+ ): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
59
+ Effect.gen(function* () {
60
+ if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
61
+ const found = yield* readRoleAt(spec);
62
+ if (found === undefined || !spec.matches(found.attributes)) {
63
+ const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
64
+ if (lost.length > 0) {
65
+ return yield* refuse(
66
+ spec,
67
+ `live carries ${lost.join(', ')}, which this resource does not declare and the write ` +
68
+ 'would reset. Fold it into the declaration, or clear it by hand first.',
69
+ );
70
+ }
71
+ // ★ PUT, as `bao write` sends (api/logical.go); http/logical.go maps PUT and POST alike.
72
+ yield* baoWrite('PUT', spec.path, spec.body);
73
+ }
74
+ const after = yield* readRoleAt(spec);
75
+ if (after === undefined) return yield* refuse(spec, 'write reported success but it is absent.');
76
+ if (!spec.matches(after.attributes)) {
77
+ return yield* refuse(
78
+ spec,
79
+ 'wrote cleanly but read back different — OpenBao stored something other than what was ' +
80
+ 'declared. Read it by hand before retrying.',
81
+ );
82
+ }
83
+ return after.attributes;
84
+ });