@homeflare/alchemy 0.6.0 → 0.8.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 (219) hide show
  1. package/README.md +74 -41
  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 +91 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +34 -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 +54 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +22 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +970 -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 +51 -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 +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/fake-sudo.d.ts +50 -0
  53. package/dist/launchd/fake-sudo.d.ts.map +1 -0
  54. package/dist/launchd/host-effect.d.ts +18 -9
  55. package/dist/launchd/host-effect.d.ts.map +1 -1
  56. package/dist/launchd/host-file-form.d.ts +5 -0
  57. package/dist/launchd/host-file-form.d.ts.map +1 -1
  58. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  59. package/dist/launchd/index.d.ts +2 -0
  60. package/dist/launchd/index.d.ts.map +1 -1
  61. package/dist/launchd/index.js +342 -8
  62. package/dist/launchd/index.js.map +16 -12
  63. package/dist/launchd/job-preflight.d.ts.map +1 -1
  64. package/dist/launchd/job-validate.d.ts +2 -0
  65. package/dist/launchd/job-validate.d.ts.map +1 -1
  66. package/dist/launchd/local-runner.d.ts +2 -0
  67. package/dist/launchd/local-runner.d.ts.map +1 -1
  68. package/dist/launchd/providers.d.ts +2 -1
  69. package/dist/launchd/providers.d.ts.map +1 -1
  70. package/dist/launchd/runner.d.ts +7 -5
  71. package/dist/launchd/runner.d.ts.map +1 -1
  72. package/dist/launchd/secret-tripwire.d.ts +1 -0
  73. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  74. package/dist/launchd/sudo-allowlist.d.ts +47 -0
  75. package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
  76. package/dist/launchd/sudo-guard.d.ts +56 -0
  77. package/dist/launchd/sudo-guard.d.ts.map +1 -0
  78. package/dist/launchd/sudo-runner.d.ts +60 -0
  79. package/dist/launchd/sudo-runner.d.ts.map +1 -0
  80. package/dist/launchd/sudo-stage.d.ts +7 -0
  81. package/dist/launchd/sudo-stage.d.ts.map +1 -0
  82. package/dist/openbao/auth-role-form.d.ts +0 -9
  83. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  84. package/dist/openbao/auth-role.d.ts +3 -2
  85. package/dist/openbao/auth-role.d.ts.map +1 -1
  86. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  87. package/dist/openbao/fake-engines-roles.d.ts +32 -0
  88. package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
  89. package/dist/openbao/fake-engines.d.ts +36 -0
  90. package/dist/openbao/fake-engines.d.ts.map +1 -0
  91. package/dist/openbao/fake-families.d.ts +49 -0
  92. package/dist/openbao/fake-families.d.ts.map +1 -0
  93. package/dist/openbao/fake-stack.d.ts +29 -0
  94. package/dist/openbao/fake-stack.d.ts.map +1 -0
  95. package/dist/openbao/host-approles.d.ts.map +1 -1
  96. package/dist/openbao/index.js +393 -234
  97. package/dist/openbao/index.js.map +23 -19
  98. package/dist/openbao/jwt-config.d.ts +5 -0
  99. package/dist/openbao/jwt-config.d.ts.map +1 -1
  100. package/dist/openbao/jwt-role.d.ts +2 -1
  101. package/dist/openbao/jwt-role.d.ts.map +1 -1
  102. package/dist/openbao/kubernetes-role.d.ts +2 -1
  103. package/dist/openbao/kubernetes-role.d.ts.map +1 -1
  104. package/dist/openbao/mfa-enforcement.d.ts +6 -3
  105. package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
  106. package/dist/openbao/mfa-totp.d.ts.map +1 -1
  107. package/dist/openbao/pki-role.d.ts.map +1 -1
  108. package/dist/openbao/plugin.d.ts.map +1 -1
  109. package/dist/openbao/policy.d.ts +2 -0
  110. package/dist/openbao/policy.d.ts.map +1 -1
  111. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  112. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  113. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  114. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  115. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  116. package/dist/openbao/rename-identity.d.ts +69 -0
  117. package/dist/openbao/rename-identity.d.ts.map +1 -0
  118. package/dist/openbao/rename.d.ts +107 -0
  119. package/dist/openbao/rename.d.ts.map +1 -0
  120. package/dist/openbao/ssh-role-erase.d.ts +11 -0
  121. package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
  122. package/dist/openbao/ssh-role.d.ts.map +1 -1
  123. package/docs/caddy-admin.md +24 -0
  124. package/docs/caddy.md +199 -0
  125. package/docs/launchd-sudo.md +122 -0
  126. package/docs/launchd.md +7 -4
  127. package/docs/mesh-node.md +188 -0
  128. package/docs/peers.md +39 -0
  129. package/package.json +7 -2
  130. package/src/caddy/admin-calls.ts +128 -0
  131. package/src/caddy/admin-guard.ts +170 -0
  132. package/src/caddy/admin.ts +74 -0
  133. package/src/caddy/adopt-scope.test.ts +82 -0
  134. package/src/caddy/caddyfile-tripwire.ts +133 -0
  135. package/src/caddy/config-adopt.test.ts +235 -0
  136. package/src/caddy/config-form.ts +64 -0
  137. package/src/caddy/config-lifecycle.test.ts +196 -0
  138. package/src/caddy/config-lifecycle.ts +239 -0
  139. package/src/caddy/config.ts +153 -0
  140. package/src/caddy/digest.ts +44 -0
  141. package/src/caddy/fake-caddy.ts +179 -0
  142. package/src/caddy/guards.test.ts +156 -0
  143. package/src/caddy/index.ts +26 -0
  144. package/src/caddy/local-admin.test.ts +160 -0
  145. package/src/caddy/local-admin.ts +189 -0
  146. package/src/caddy/providers.test.ts +229 -0
  147. package/src/caddy/providers.ts +17 -0
  148. package/src/caddy/with-file.ts +66 -0
  149. package/src/cloudflare/fake-mesh.ts +181 -0
  150. package/src/cloudflare/index.ts +9 -3
  151. package/src/cloudflare/mesh-node-api.ts +127 -0
  152. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  153. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  154. package/src/cloudflare/mesh-node-form.ts +115 -0
  155. package/src/cloudflare/mesh-node-harness.ts +124 -0
  156. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  157. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  158. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  159. package/src/cloudflare/mesh-node-token.ts +125 -0
  160. package/src/cloudflare/mesh-node.test.ts +172 -0
  161. package/src/cloudflare/mesh-node.ts +120 -0
  162. package/src/cloudflare/providers.ts +20 -2
  163. package/src/index.ts +1 -0
  164. package/src/launchd/fake-sudo.ts +136 -0
  165. package/src/launchd/host-effect.ts +33 -1
  166. package/src/launchd/host-file-form.ts +10 -2
  167. package/src/launchd/host-file-lifecycle.ts +2 -1
  168. package/src/launchd/index.ts +2 -0
  169. package/src/launchd/job-preflight.ts +3 -2
  170. package/src/launchd/job-validate.ts +1 -1
  171. package/src/launchd/local-runner.ts +6 -3
  172. package/src/launchd/providers.test.ts +3 -0
  173. package/src/launchd/providers.ts +2 -1
  174. package/src/launchd/runner.ts +7 -5
  175. package/src/launchd/secret-tripwire.ts +1 -1
  176. package/src/launchd/sudo-allowlist.test.ts +191 -0
  177. package/src/launchd/sudo-allowlist.ts +213 -0
  178. package/src/launchd/sudo-guard.test.ts +100 -0
  179. package/src/launchd/sudo-guard.ts +151 -0
  180. package/src/launchd/sudo-lifecycle.test.ts +172 -0
  181. package/src/launchd/sudo-runner-files.test.ts +240 -0
  182. package/src/launchd/sudo-runner.test.ts +126 -0
  183. package/src/launchd/sudo-runner.ts +235 -0
  184. package/src/launchd/sudo-stage.ts +28 -0
  185. package/src/openbao/README.md +8 -2
  186. package/src/openbao/REPLACE.md +126 -22
  187. package/src/openbao/auth-role-form.test.ts +2 -16
  188. package/src/openbao/auth-role-form.ts +0 -11
  189. package/src/openbao/auth-role.ts +35 -14
  190. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  191. package/src/openbao/cloudflare-role.ts +22 -8
  192. package/src/openbao/fake-engines-roles.ts +114 -0
  193. package/src/openbao/fake-engines.ts +126 -0
  194. package/src/openbao/fake-families.ts +182 -0
  195. package/src/openbao/fake-stack.ts +112 -0
  196. package/src/openbao/host-approles.test.ts +15 -2
  197. package/src/openbao/host-approles.ts +19 -4
  198. package/src/openbao/jwt-config.ts +26 -5
  199. package/src/openbao/jwt-role.ts +24 -8
  200. package/src/openbao/kubernetes-role.ts +20 -8
  201. package/src/openbao/mfa-enforcement.ts +23 -8
  202. package/src/openbao/mfa-totp.ts +21 -8
  203. package/src/openbao/pki-role.ts +19 -7
  204. package/src/openbao/plugin.ts +41 -15
  205. package/src/openbao/policy-rename.test.ts +112 -0
  206. package/src/openbao/policy-wire.test.ts +7 -26
  207. package/src/openbao/policy.ts +27 -4
  208. package/src/openbao/proxmox-role-form.ts +27 -8
  209. package/src/openbao/proxmox-role-wire.ts +24 -0
  210. package/src/openbao/proxmox-role.ts +52 -36
  211. package/src/openbao/rename-adoption.test.ts +82 -0
  212. package/src/openbao/rename-families.test.ts +122 -0
  213. package/src/openbao/rename-identity.test.ts +129 -0
  214. package/src/openbao/rename-identity.ts +117 -0
  215. package/src/openbao/rename-occupied.test.ts +166 -0
  216. package/src/openbao/rename.ts +166 -0
  217. package/src/openbao/role-rename.test.ts +205 -0
  218. package/src/openbao/ssh-role-erase.ts +50 -0
  219. package/src/openbao/ssh-role.ts +16 -54
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters:
3
- * a write parses the duration text into seconds and re-marshals `policies` (path_roles.go:117-146,
4
- * :199-210), a role never written is a 404, and a delete answers 204 whether or not it existed.
2
+ * Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
3
+ * (fake-engines.ts): a write parses the duration text into seconds and re-marshals `policies`
4
+ * (path_roles.go:117-146, :199-210), a role never written is a 404, and a delete answers 204
5
+ * whether or not it existed.
5
6
  */
6
7
  import assert from 'node:assert/strict';
7
8
  import { describe, it } from 'node:test';
@@ -13,8 +14,8 @@ import {
13
14
  readCloudflareRole,
14
15
  writeCloudflareRole,
15
16
  } from './cloudflare-role-wire.ts';
16
- import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
17
- import { parseDuration } from './mount-form.ts';
17
+ import { run, runFailure, withFake } from './fake-bao.ts';
18
+ import { cloudflareRoles } from './fake-engines.ts';
18
19
 
19
20
  const PROPS: BaoCloudflareRoleProps = {
20
21
  description: 'Read DNS records on one zone. (zone: example.com)',
@@ -34,36 +35,9 @@ const PROPS: BaoCloudflareRoleProps = {
34
35
 
35
36
  const PATH = '/v1/cloudflare-acme-dns/roles/example-com-dns-read';
36
37
 
37
- /** The engine's role store, reduced to what these calls can observe. */
38
- const roleStore = () => {
39
- const roles = new Map<string, Record<string, unknown>>();
40
- return (seen: Seen): Reply => {
41
- if (seen.method === 'PUT') {
42
- const body = JSON.parse(seen.body) as Record<string, string>;
43
- roles.set(seen.path, {
44
- description: body['description'],
45
- max_ttl: parseDuration(body['max_ttl'] ?? ''),
46
- name: seen.path.split('/').at(-1),
47
- // ★ Re-marshalled, as json.Marshal of the stored structs would be.
48
- policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
49
- ttl: parseDuration(body['ttl'] ?? ''),
50
- });
51
- return { status: 204 };
52
- }
53
- if (seen.method === 'DELETE') {
54
- roles.delete(seen.path);
55
- return { status: 204 };
56
- }
57
- const role = roles.get(seen.path);
58
- return role === undefined
59
- ? { json: { errors: [] }, status: 404 }
60
- : { json: { data: role }, status: 200 };
61
- };
62
- };
63
-
64
38
  describe('Cloudflare role wire', () => {
65
39
  it('reads a role that was never written as absent', async () => {
66
- await withFake(roleStore(), async (bao) => {
40
+ await withFake(cloudflareRoles(), async (bao) => {
67
41
  const env = { BAO_ADDR: bao.address };
68
42
  assert.equal(await run(env, readCloudflareRole(PROPS.mount, PROPS.name)), undefined);
69
43
  assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['GET', PATH]);
@@ -71,7 +45,7 @@ describe('Cloudflare role wire', () => {
71
45
  });
72
46
 
73
47
  it('writes the four fields and reads them back with no difference', async () => {
74
- await withFake(roleStore(), async (bao) => {
48
+ await withFake(cloudflareRoles(), async (bao) => {
75
49
  const env = { BAO_ADDR: bao.address };
76
50
  const { policies } = resolvePolicies(
77
51
  PROPS.policies,
@@ -38,6 +38,7 @@ import {
38
38
  readCloudflareRole,
39
39
  writeCloudflareRole,
40
40
  } from './cloudflare-role-wire.ts';
41
+ import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
41
42
 
42
43
  export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
43
44
 
@@ -59,6 +60,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
59
60
  defaultRemovalPolicy: 'retain',
60
61
  });
61
62
 
63
+ /** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
64
+ const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
65
+
62
66
  export const BaoCloudflareRoleProvider = () =>
63
67
  Provider.effect(
64
68
  BaoCloudflareRole,
@@ -112,14 +116,22 @@ export const BaoCloudflareRoleProvider = () =>
112
116
  * OpenBao UI changes what every future token can do while the digest in Postgres still
113
117
  * says everything is fine.
114
118
  *
115
- * ⚠️ NO `replace`, EVEN FOR A NAME OR MOUNT CHANGE. A different path is a different object,
116
- * so diff reads the new path, finds it absent and plans `update`; reconcile creates it and
117
- * the old role is left live, retained and unmanaged — the same outcome apply-roles.py's
118
- * UNTRACKED report had. declareCloudflareRoles puts mount and name in the resource id, so
119
- * a rename there is a new resource anyway.
119
+ * ⛔ A NEW `mount` OR `name` IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
120
+ * 2026-09-21 this read the new path, found it absent and planned `update`: reconcile wrote
121
+ * the new role and the old one stayed live and mintable under no state record. ⛔ A move
122
+ * onto a role that already exists fails the plan (`judgeMove`).
123
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL MINTS for any token whose policy reaches its
124
+ * `creds/<name>`. Under `destroy` every consumer still minting from the old path fails at
125
+ * the delete, so move them in the same PR (REPLACE.md).
126
+ * ★ A STACK THAT PUTS mount AND name IN THE LOGICAL ID never reaches this branch
127
+ * (homeflare-openbao's declareCloudflareRoles does). There a rename is a new logical id,
128
+ * and the old id leaves the stack as an orphan delete, which `retain` keeps live.
120
129
  */
121
- diff: Effect.fn(function* ({ news, output }) {
122
- if (output === undefined || !isResolved(news)) return undefined;
130
+ diff: Effect.fn(function* ({ news, olds, output }) {
131
+ const move = yield* judgeRename(IDENTITY, olds, news, output);
132
+ if (output === undefined) return undefined;
133
+ if (move !== undefined) return { action: 'replace' } as const;
134
+ if (!isResolved(news)) return undefined;
123
135
  /**
124
136
  * ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop — route it to reconcile,
125
137
  * which says why (pki-role.ts has the same guard).
@@ -133,8 +145,10 @@ export const BaoCloudflareRoleProvider = () =>
133
145
  : ({ action: 'update' } as const);
134
146
  }),
135
147
 
136
- reconcile: Effect.fn(function* ({ news }) {
148
+ reconcile: Effect.fn(function* ({ news, output }) {
137
149
  const path = rolePath(news.mount, news.name);
150
+ // ⛔ An `update` across a move the diff could not see — refused before any write.
151
+ yield* guardRename(IDENTITY, news, output);
138
152
  const refusal = refusalOf(news);
139
153
  if (refusal !== undefined) {
140
154
  return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Fakes of the stores behind the batch-2 families, for fake-bao: every role engine, JWT auth config
3
+ * and login-MFA enforcements (`wireRoles`), the TOTP method table (`totpMethods`) and the plugin
4
+ * catalog (`pluginCatalog`), behind one answer (`fakeEstate`). As in fake-engines.ts, each keys its
5
+ * objects the way the real server does, because a rename test is only as good as the fake's idea of
6
+ * "the same object".
7
+ *
8
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
9
+ */
10
+ import type { Reply, Seen } from './fake-bao.ts';
11
+ import { ABSENT, type Store, type Stored, lastSegment, store } from './fake-engines.ts';
12
+
13
+ /**
14
+ * The role stores whose server lowercases the name: AppRole (openbao v2.6.2
15
+ * builtin/credential/approle/path_role.go:1485 and :1893), Kubernetes
16
+ * (builtin/credential/kubernetes backend.go:362, path_role.go:250 and :396) and JWT, whose `name`
17
+ * field is a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77; sdk v2.6.2
18
+ * framework/field_data.go:250-255 lowercases it on read). PKI and SSH store the name verbatim. The
19
+ * tests mount all three at their default paths.
20
+ */
21
+ const FOLDED = /^auth\/(approle|jwt|kubernetes)\/role\/[^/]+$/;
22
+
23
+ const keyOf = (path: string): string =>
24
+ FOLDED.test(path) ? path.replace(/[^/]+$/, (name) => name.toLowerCase()) : path;
25
+
26
+ /** Fields a write sends comma-joined that the server reads back as a list. */
27
+ const LISTS = new Set([
28
+ 'allowed_domains',
29
+ 'cn_validations',
30
+ 'ext_key_usage',
31
+ 'key_usage',
32
+ 'token_policies',
33
+ ]);
34
+
35
+ /**
36
+ * The server's typed view of a `k=v` body: `true`, `false` and digit strings become values, and the
37
+ * LISTS split. Arrays, objects and duration strings pass through, and every reader here takes a
38
+ * duration string as well as seconds (`ttlSeconds`).
39
+ */
40
+ const typed = (body: Stored): Stored => {
41
+ const out: Stored = {};
42
+ for (const [field, value] of Object.entries(body)) {
43
+ if (typeof value !== 'string') out[field] = value;
44
+ else if (LISTS.has(field)) out[field] = value.split(',').filter((part) => part !== '');
45
+ else if (value === 'true' || value === 'false') out[field] = value === 'true';
46
+ else out[field] = /^\d+$/.test(value) ? Number(value) : value;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
52
+ export const wireRoles = (): Store =>
53
+ store<Stored>(
54
+ (_path, body) => typed(body),
55
+ (path) => keyOf(path),
56
+ );
57
+
58
+ /**
59
+ * `sys/plugins/catalog/<type>/<name>`. A register names its version in the body; a read or a delete
60
+ * names it in `?version=` (plugin-form.ts versionedPath).
61
+ */
62
+ export const pluginCatalog = (): Store =>
63
+ store<Stored>(
64
+ (path, body) => ({ ...body, builtin: false, name: lastSegment(path.split('?')[0] ?? '') }),
65
+ (path, body) => {
66
+ const version = body?.['version'];
67
+ return typeof version === 'string' && version !== '' ? `${path}?version=${version}` : path;
68
+ },
69
+ );
70
+
71
+ /**
72
+ * `identity/mfa/method/totp`: an upsert by `method_name` (identity/mfa.go:159-230), listed with
73
+ * every other type under `identity/mfa/method?list=true` (mfa-wire.ts). Keyed by the server-made
74
+ * id.
75
+ */
76
+ export const totpMethods = (): Store => {
77
+ const live = new Map<string, Stored>();
78
+ let made = 0;
79
+ const answer = (seen: Seen): Reply => {
80
+ const path = seen.path.replace(/^\/v1\//, '');
81
+ if (seen.method === 'POST') {
82
+ const body = JSON.parse(seen.body) as Stored;
83
+ const name = body['method_name'];
84
+ const found = [...live].find(([, method]) => method['name'] === name)?.[0];
85
+ made += found === undefined ? 1 : 0;
86
+ const id = found ?? `method-${String(made)}`;
87
+ live.set(id, { ...body, id, name, namespace_path: '', type: 'totp' });
88
+ return { json: { data: { method_id: id } }, status: 200 };
89
+ }
90
+ if (seen.method === 'DELETE') {
91
+ live.delete(lastSegment(path));
92
+ return { status: 204 };
93
+ }
94
+ if (path !== 'identity/mfa/method?list=true' || live.size === 0) return ABSENT;
95
+ const info = Object.fromEntries(live);
96
+ return { json: { data: { key_info: info, keys: [...live.keys()] } }, status: 200 };
97
+ };
98
+ return Object.assign(answer, { live });
99
+ };
100
+
101
+ /** All three behind one answer, routed by path, for a stack that mixes the families. */
102
+ export const fakeEstate = () => {
103
+ const roles = wireRoles();
104
+ const plugins = pluginCatalog();
105
+ const totp = totpMethods();
106
+ const answer = (seen: Seen): Reply => {
107
+ if (seen.path.startsWith('/v1/identity/mfa/method')) return totp(seen);
108
+ if (seen.path.startsWith('/v1/sys/plugins/catalog/')) return plugins(seen);
109
+ return roles(seen);
110
+ };
111
+ return Object.assign(answer, { plugins, roles, totp });
112
+ };
113
+
114
+ export type Estate = ReturnType<typeof fakeEstate>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
3
+ * plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
4
+ * real server does. That is the point: a rename test is only as good as the fake's idea of "the
5
+ * same object". `store` is shared with fake-engines-roles.ts, which fakes the batch-2 families.
6
+ *
7
+ * ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
8
+ */
9
+ import * as Effect from 'effect/Effect';
10
+ import * as FileSystem from 'effect/FileSystem';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as Path from 'effect/Path';
13
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
14
+ import type { Reply, Seen } from './fake-bao.ts';
15
+ import { parseDuration } from './mount-form.ts';
16
+ import { trimTrailing } from './mount-path.ts';
17
+ import { BaoPolicyProvider } from './policy.ts';
18
+ import { policyKey } from './rename.ts';
19
+
20
+ export type Stored = Record<string, unknown>;
21
+
22
+ /** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
23
+ export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
24
+
25
+ export const ABSENT: Reply = { json: { errors: [] }, status: 404 };
26
+
27
+ /**
28
+ * A store of objects by API path. `key` turns a request path (and a write's body) into the key the
29
+ * server would store it under.
30
+ */
31
+ export const store = <B extends Stored = Record<string, string>>(
32
+ write: (path: string, body: B, before: Stored | undefined) => Stored,
33
+ key: (path: string, body?: B) => string = (path) => path,
34
+ ): Store => {
35
+ const live = new Map<string, Stored>();
36
+ const answer = (seen: Seen): Reply => {
37
+ const raw = seen.path.replace(/^\/v1\//, '');
38
+ // ★ `bao write` sends PUT, and the identity endpoints are called with POST. Both write.
39
+ if (seen.method === 'PUT' || seen.method === 'POST') {
40
+ const body = JSON.parse(seen.body) as B;
41
+ const path = key(raw, body);
42
+ live.set(path, write(path, body, live.get(path)));
43
+ return { status: 204 };
44
+ }
45
+ const path = key(raw);
46
+ // ★ A delete answers 204 whether or not the object existed, as every engine here does.
47
+ if (seen.method === 'DELETE') {
48
+ live.delete(path);
49
+ return { status: 204 };
50
+ }
51
+ const data = live.get(path);
52
+ return data === undefined ? ABSENT : { json: { data }, status: 200 };
53
+ };
54
+ return Object.assign(answer, { live });
55
+ };
56
+
57
+ export const lastSegment = (path: string): string => path.split('/').at(-1) ?? '';
58
+
59
+ /**
60
+ * `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
61
+ * server — digest.ts), and keys the name as OpenBao does: trimmed and lowercased (`policyKey`).
62
+ */
63
+ export const aclPolicies = (): Store =>
64
+ store(
65
+ (path, body) => ({
66
+ name: lastSegment(path),
67
+ policy: trimTrailing(body['policy'] ?? '', '\n'),
68
+ }),
69
+ (path) =>
70
+ path.startsWith('sys/policies/acl/')
71
+ ? `sys/policies/acl/${policyKey(decodeURIComponent(lastSegment(path)))}`
72
+ : path,
73
+ );
74
+
75
+ /**
76
+ * `<mount>/roles/<name>` on the Cloudflare engine. A write parses the duration text into seconds and
77
+ * re-marshals `policies` (path_roles.go:117-146, :199-210). Names are stored verbatim.
78
+ */
79
+ export const cloudflareRoles = (): Store =>
80
+ store((path, body) => ({
81
+ description: body['description'],
82
+ max_ttl: parseDuration(body['max_ttl'] ?? ''),
83
+ name: lastSegment(path),
84
+ // ★ Re-marshalled, as json.Marshal of the stored structs would be.
85
+ policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
86
+ ttl: parseDuration(body['ttl'] ?? ''),
87
+ }));
88
+
89
+ /**
90
+ * `<mount>/roles/<name>` on the proxmox engine. A write MERGES into the stored role, and TTLs go over
91
+ * as seconds (homeflare-openbao-plugins secrets/proxmox/path_roles.go, pathRolesWrite).
92
+ */
93
+ export const proxmoxRoles = (): Store =>
94
+ store((path, body, before) => ({
95
+ max_ttl: 0,
96
+ name: lastSegment(path),
97
+ ttl: 0,
98
+ ...before,
99
+ ...(body['mint_user'] === undefined ? {} : { mint_user: body['mint_user'] }),
100
+ ...(body['ttl'] === undefined ? {} : { ttl: Number(body['ttl']) }),
101
+ ...(body['max_ttl'] === undefined ? {} : { max_ttl: Number(body['max_ttl']) }),
102
+ }));
103
+
104
+ /**
105
+ * Bao.Policy's provider over fragment directories that exist only here: `…/empty` holds no file,
106
+ * `…/two` holds two, and any other directory one. Each file is one grant named after its own path,
107
+ * so a directory's content is its name.
108
+ */
109
+ export const fakePolicyProviders = Layer.mergeAll(
110
+ BaoPolicyProvider().pipe(
111
+ Layer.provide(
112
+ Layer.mergeAll(
113
+ FileSystem.layerNoop({
114
+ readDirectory: (dir) =>
115
+ Effect.succeed(
116
+ dir.endsWith('/empty') ? [] : dir.endsWith('/two') ? ['a.hcl', 'b.hcl'] : ['a.hcl'],
117
+ ),
118
+ readFileString: (path) =>
119
+ Effect.succeed(`path "${path}" {\n capabilities = ["read"]\n}`),
120
+ }),
121
+ Path.layer,
122
+ ),
123
+ ),
124
+ ),
125
+ FetchHttpClient.layer,
126
+ );
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The nine batch-2 families as rows of one table, so a rename test can run each of them through
3
+ * Alchemy's own plan and apply (fake-stack.ts) against one fake (fake-engines-roles.ts). Every row
4
+ * declares a resource whose identity is `name` (for Bao.JwtAuthConfig, the mount), with `knob`, a
5
+ * duration, in a prop that is NOT its identity: the pending Output in "a rename lands with another
6
+ * pending prop".
7
+ *
8
+ * ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
9
+ */
10
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
11
+ import type * as Output from 'alchemy/Output';
12
+ import * as Effect from 'effect/Effect';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+ import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
16
+ import type { Seen } from './fake-bao.ts';
17
+ import { type Estate, fakeEstate } from './fake-engines-roles.ts';
18
+ import { type FakeStack, type StackBody, withFakeStack } from './fake-stack.ts';
19
+ import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
20
+ import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
21
+ import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
22
+ import { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
23
+ import { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
24
+ import { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
25
+ import { BaoPlugin, BaoPluginProvider } from './plugin.ts';
26
+ import { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
27
+
28
+ export const familyProviders = Layer.mergeAll(
29
+ BaoAuthRoleProvider(),
30
+ BaoJwtAuthConfigProvider(),
31
+ BaoJwtRoleProvider(),
32
+ BaoKubernetesRoleProvider(),
33
+ BaoMfaLoginEnforcementProvider(),
34
+ BaoMfaTotpMethodProvider(),
35
+ BaoPkiRoleProvider(),
36
+ BaoPluginProvider(),
37
+ BaoSshRoleProvider(),
38
+ FetchHttpClient.layer,
39
+ );
40
+
41
+ /** One stack over a fresh estate: every family's provider, and every call the fake saw. */
42
+ export const withEstate = (
43
+ body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
44
+ ): Promise<void> => {
45
+ const estate = fakeEstate();
46
+ return withFakeStack(familyProviders, estate, (stack, bao) => body(stack, estate, bao.seen));
47
+ };
48
+
49
+ /** The row for one family, by name. */
50
+ export const rowOf = (family: string): Family => {
51
+ const row = FAMILIES.find((each) => each.family === family);
52
+ if (row === undefined) throw new Error(`no fake-families row for ${family}`);
53
+ return row;
54
+ };
55
+
56
+ type Text = string | Output.Output<string>;
57
+ type Removal = typeof RemovalPolicy.destroy;
58
+
59
+ export interface Family {
60
+ readonly family: string;
61
+ /** One resource, logical id `id`, whose identity is `name`. */
62
+ readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
63
+ /** Whether the fake holds the object `name` names. */
64
+ readonly has: (estate: Estate, name: string) => boolean;
65
+ }
66
+
67
+ const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
68
+ estate.roles.live.has(path(name));
69
+
70
+ const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
71
+ const SHA = 'a'.repeat(64);
72
+
73
+ export const FAMILIES: readonly Family[] = [
74
+ {
75
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
76
+ BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
77
+ family: 'Bao.AuthRole',
78
+ has: role((name) => `auth/approle/role/${name}`),
79
+ },
80
+ {
81
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
82
+ BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
83
+ removal(),
84
+ ),
85
+ family: 'Bao.PkiRole',
86
+ has: role((name) => `pki/roles/${name}`),
87
+ },
88
+ {
89
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
90
+ BaoJwtRole(id, {
91
+ boundAudiences: ['https://example.com'],
92
+ name,
93
+ roleType: 'jwt',
94
+ tokenPolicies: ['ci'],
95
+ tokenTtl: knob,
96
+ userClaim: 'sub',
97
+ }).pipe(removal()),
98
+ family: 'Bao.JwtRole',
99
+ has: role((name) => `auth/jwt/role/${name}`),
100
+ },
101
+ {
102
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
103
+ BaoKubernetesRole(id, {
104
+ aliasNameSource: 'serviceaccount_uid',
105
+ boundServiceAccountNames: ['app'],
106
+ boundServiceAccountNamespaces: ['apps'],
107
+ name,
108
+ tokenPolicies: ['app'],
109
+ tokenTtl: knob,
110
+ }).pipe(removal()),
111
+ family: 'Bao.KubernetesRole',
112
+ has: role((name) => `auth/kubernetes/role/${name}`),
113
+ },
114
+ {
115
+ declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
116
+ BaoJwtAuthConfig(id, {
117
+ boundIssuer: knob,
118
+ jwksUrl: 'https://issuer.example.com/keys',
119
+ mount,
120
+ }).pipe(removal()),
121
+ family: 'Bao.JwtAuthConfig',
122
+ has: role((mount) => `auth/${mount}/config`),
123
+ },
124
+ {
125
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
126
+ BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
127
+ family: 'Bao.MfaTotpMethod',
128
+ has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
129
+ },
130
+ {
131
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
132
+ BaoMfaLoginEnforcement(id, {
133
+ authMethodTypes: ['jwt'],
134
+ mfaMethodIds: [knob],
135
+ name,
136
+ }).pipe(removal()),
137
+ family: 'Bao.MfaLoginEnforcement',
138
+ has: role((name) => `identity/mfa/login-enforcement/${name}`),
139
+ },
140
+ {
141
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
142
+ BaoSshRole(id, {
143
+ allowUserCertificates: true,
144
+ allowedUsers: ['ops'],
145
+ maxTtl: '8h',
146
+ name,
147
+ ttl: knob,
148
+ }).pipe(removal()),
149
+ family: 'Bao.SshRole',
150
+ has: role((name) => `ssh/roles/${name}`),
151
+ },
152
+ {
153
+ declare: (id, name, knob, removal = RemovalPolicy.retain) =>
154
+ BaoPlugin(id, {
155
+ args: [knob],
156
+ command: 'plugin-example',
157
+ name,
158
+ sha256: SHA,
159
+ type: 'secret',
160
+ version: 'v1.0.0',
161
+ }).pipe(removal()),
162
+ family: 'Bao.Plugin',
163
+ has: (estate, name) =>
164
+ estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
165
+ },
166
+ ];
167
+
168
+ /**
169
+ * Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
170
+ * differ, so a generation that lands on the other's name has to write over it.
171
+ */
172
+ export const pairOf =
173
+ (row: Family) =>
174
+ (x: string, y: string): StackBody =>
175
+ Effect.gen(function* () {
176
+ yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
177
+ yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
178
+ });
179
+
180
+ /** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
181
+ export const upstream = (ttl: string) =>
182
+ BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Alchemy's own Plan and Apply, run in-process over an in-memory state store, so a test can deploy a
3
+ * declaration, change it, deploy again, and see what the ENGINE did with a provider's answers: which
4
+ * action it planned, and which calls reached the fake.
5
+ *
6
+ * ★ WHY THE ENGINE AND NOT THE HANDLERS ALONE. A diff that answers `replace` proves nothing by itself.
7
+ * What matters is that Apply then creates the new generation, deletes the OLD one with the OLD
8
+ * attributes, and keeps it under `retain`. Those are claims about alchemy@2.0.0-beta.79, and this
9
+ * harness measures them instead of restating them (REPLACE.md).
10
+ * ★ NOT alchemy/Test/Bun. Its runtime builds the CLI's profile and credentials stores, which read
11
+ * files under the home directory, plus a file logger. This needs none of that: the providers, an
12
+ * in-memory state store, a stage, and the stack's own services. It is the composition
13
+ * alchemy/src/Deploy.ts uses, minus the platform layer.
14
+ *
15
+ * ⛔ TEST-ONLY, AND NEVER A REAL VAULT. `BaoEnv` is provided explicitly, so a BAO_ADDR or BAO_TOKEN
16
+ * in the shell running the tests is never read. No provider imports this file.
17
+ */
18
+ import * as Alchemy from 'alchemy';
19
+ import * as Effect from 'effect/Effect';
20
+ import * as Layer from 'effect/Layer';
21
+ import type { BaoEnvironment } from './bao-address.ts';
22
+ import { BaoEnv } from './bao-http.ts';
23
+ import { type Fake, type Reply, type Seen, fakeBao } from './fake-bao.ts';
24
+
25
+ /** The planned action for each resource, deletions included, keyed by FQN. */
26
+ export type Planned = Readonly<Record<string, string>>;
27
+
28
+ /** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
29
+ export type StackBody = Effect.Effect<unknown, unknown, unknown>;
30
+
31
+ export interface FakeStack {
32
+ /** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
33
+ readonly deploy: (body: StackBody) => Promise<Planned>;
34
+ }
35
+
36
+ type Node = { readonly action: string };
37
+ type PlanView = {
38
+ readonly resources: Readonly<Record<string, Node>>;
39
+ readonly deletions: Readonly<Record<string, Node | undefined>>;
40
+ };
41
+
42
+ const actionsOf = (plan: PlanView): Planned => {
43
+ const out: Record<string, string> = {};
44
+ for (const [fqn, node] of Object.entries(plan.resources)) out[fqn] = node.action;
45
+ for (const [fqn, node] of Object.entries(plan.deletions)) {
46
+ if (node !== undefined) out[fqn] = node.action;
47
+ }
48
+ return out;
49
+ };
50
+
51
+ /**
52
+ * One stack over one state store, shared by every `deploy`, so the second deploy plans against what
53
+ * the first one recorded.
54
+ *
55
+ * ⚠️ THE CASTS ARE AT THE ENGINE'S TYPED BOUNDARY, NOT IN THE PROVIDERS. `Alchemy.Stack` types its body
56
+ * against every service a CLI run provides. Here the body needs only the providers given, and a
57
+ * missing one fails the test by name ("Provider not found for …").
58
+ */
59
+ export const fakeStack = <ROut, E, RIn>(
60
+ providers: Layer.Layer<ROut, E, RIn>,
61
+ env: BaoEnvironment,
62
+ name = 'RenameStack',
63
+ ): FakeStack => {
64
+ const state = Alchemy.inMemoryState();
65
+ const stack = Alchemy.Stack as unknown as (
66
+ stackName: string,
67
+ options: { providers: typeof providers; state: typeof state },
68
+ body: StackBody,
69
+ ) => Effect.Effect<{ readonly services: never }, unknown, never>;
70
+ const plan = Alchemy.Plan.make as unknown as (
71
+ compiled: unknown,
72
+ ) => Effect.Effect<PlanView, unknown, never>;
73
+ const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
74
+
75
+ const deploy = (body: StackBody) =>
76
+ Effect.gen(function* () {
77
+ const compiled = yield* stack(name, { providers, state }, body);
78
+ return yield* Effect.gen(function* () {
79
+ const planned = yield* plan(compiled);
80
+ yield* apply(planned);
81
+ return actionsOf(planned);
82
+ }).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
83
+ }).pipe(
84
+ Effect.provideService(Alchemy.Stage, 'test'),
85
+ Effect.provideService(BaoEnv, env),
86
+ Effect.provide(state),
87
+ Effect.scoped,
88
+ );
89
+
90
+ return { deploy: (body) => Effect.runPromise(deploy(body) as Effect.Effect<Planned>) };
91
+ };
92
+
93
+ /**
94
+ * A fake engine answering with `answer`, and one stack over it, for one test. The fake stops however
95
+ * `body` ends.
96
+ */
97
+ export const withFakeStack = async <ROut, E, RIn>(
98
+ providers: Layer.Layer<ROut, E, RIn>,
99
+ answer: (seen: Seen) => Reply,
100
+ body: (stack: FakeStack, bao: Fake) => Promise<void>,
101
+ ): Promise<void> => {
102
+ const bao = fakeBao(answer);
103
+ try {
104
+ await body(fakeStack(providers, { BAO_ADDR: bao.address }), bao);
105
+ } finally {
106
+ bao.stop();
107
+ }
108
+ };
109
+
110
+ /** Every call that changed something, as `METHOD /v1/path`, in order. */
111
+ export const writesOf = (seen: readonly Seen[]): string[] =>
112
+ seen.filter((each) => each.method !== 'GET').map((each) => `${each.method} ${each.path}`);